From 222b430526320a0a6be11de8efb9774e7132ce1e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 24 Aug 2026 17:02:35 -0700 Subject: [PATCH 1/3] Wait for the sealed GitHub token before using sandbox credentials Mission Control seals the user's GitHub token to a key the host generates at startup and advertises on `register` and every heartbeat. A freshly provisioned environment can answer `/connect` before that key has propagated, returning credentials with no `encrypted_github_token`. Nothing about that looks like a failure: the relay opens and the AHP handshake completes. But without the sealed envelope the client never sends `authenticate`, so nothing establishes who the agent acts as and the host rejects every session request with `-32007 AuthRequired`. Credentials are now re-minted until they carry the envelope. The bound is sized against the daemon's own timings rather than guessed: `copilotd` heartbeats every 30s and backs off register attempts up to 60s, so 12 attempts at 5s spans a full register backoff and two heartbeats. A warm environment seals on the first mint and never enters the loop. An environment may legitimately never seal a token, so this degrades rather than failing: the last credentials are returned either way, and the degraded case is logged where the cause is visible rather than leaving only the downstream `AuthRequired` symptoms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/cloudSandboxAgentHostService.ts | 62 ++++++++- .../cloudSandboxAgentHostService.test.ts | 131 ++++++++++++++++++ 2 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index 3577d685cc51cd..9cff1eedb68cb6 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -36,6 +36,24 @@ const LOG_PREFIX = '[CloudSandboxAgentHost]'; /** Maximum number of `/connect` "waking" retries before giving up. */ const MAX_WAKING_RETRIES = 20; +/** + * Maximum number of `/connect` re-mints while the sealed GitHub token is still missing. + * + * Mission Control seals the token to a key the host generates at startup and advertises on + * `register` and every heartbeat, so a key it has not learned yet leaves nothing to seal to. A + * freshly provisioned environment can answer `/connect` before that has propagated, yielding + * credentials with no `encrypted_github_token` and a host that rejects every session request with + * `-32007 AuthRequired`. + * + * Sized against the daemon's own timings: `copilotd` heartbeats every 30s and backs off register + * attempts up to 60s, so this spans a full register backoff and two heartbeats. A warm environment + * seals on the first mint and never enters this loop. + */ +export const MAX_SEALED_TOKEN_RETRIES = 12; + +/** Delay between `/connect` re-mints while waiting for the host key to propagate. */ +const SEALED_TOKEN_RETRY_DELAY_MS = 5_000; + /** * Renderer-side coordinator for Copilot cloud sandbox connections. * @@ -54,6 +72,9 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa /** Current Web PubSub credentials per connection address, including the sealed GitHub token. */ private readonly _creds = new Map(); + /** Overridable so tests can exercise the re-mint loop without waiting on real delays. */ + protected readonly sealedTokenRetryDelayMs: number = SEALED_TOKEN_RETRY_DELAY_MS; + constructor( @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, @ICloudSandboxApiService private readonly _apiService: ICloudSandboxApiService, @@ -111,8 +132,10 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa /** * Open the relay with an already-minted token, drive the AHP handshake, and register the * connection. + * + * Protected so tests can exercise credential minting without opening a real socket. */ - private async _establish(options: ICloudSandboxConnectOptions, address: string, clientToken: ICloudSandboxClientToken, token: CancellationToken): Promise { + protected async _establish(options: ICloudSandboxConnectOptions, address: string, clientToken: ICloudSandboxClientToken, token: CancellationToken): Promise { // Mutable holder read by the transport factory: the protocol client re-invokes the factory to // soft-reconnect, picking up whatever credentials the refresh scheduler last wrote. const creds: ICloudSandboxCreds = { token: clientToken }; @@ -172,6 +195,10 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa this._logService.warn(`${LOG_PREFIX} Sealed-token authenticate failed for ${address}`, err); } } + } else if (!connectError) { + // Without an envelope every later request answers `-32007 AuthRequired`; say so here, + // where the cause is visible, rather than leaving only the downstream symptoms. + this._logService.error(`${LOG_PREFIX} Mission Control returned no sealed GitHub token for ${address}; the host cannot act as the user and session requests will fail with AuthRequired.`); } try { @@ -217,7 +244,7 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa } const result = await this._apiService.connect({ environmentId: options.environmentId, sessionId: options.sessionId }, token); if (result.kind === 'token') { - return result.token; + return await this._awaitSealedToken(options, result.token, token); } const delayMs = Math.min(result.waking.retryAfterSeconds * 1000, MAX_WAKING_DELAY_MS); this._logService.info(`${LOG_PREFIX} Environment ${options.environmentId} waking; retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_WAKING_RETRIES})`); @@ -225,4 +252,35 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa } throw new Error(`Timed out waiting for sandbox environment ${options.environmentId} to wake.`); } + + /** + * Re-mint credentials until they carry a sealed GitHub token. + * + * A newly provisioned environment answers `/connect` as soon as the compute is up, which can be + * before Mission Control has learned the host's sealing key. The credentials are valid, but + * without the sealed envelope the client never sends `authenticate`, so the host answers every + * session request with `-32007 AuthRequired`. Re-minting lets the key propagate. + * + * Returns the last credentials either way: an environment may legitimately never seal one, and a + * session that cannot reach GitHub APIs beats refusing to connect. {@link _establish} logs that. + */ + private async _awaitSealedToken(options: ICloudSandboxConnectOptions, minted: ICloudSandboxClientToken, token: CancellationToken): Promise { + let clientToken = minted; + for (let attempt = 0; attempt < MAX_SEALED_TOKEN_RETRIES && !clientToken.encrypted_github_token; attempt++) { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + this._logService.info(`${LOG_PREFIX} Environment ${options.environmentId} has no sealed GitHub token yet; re-minting in ${this.sealedTokenRetryDelayMs}ms (attempt ${attempt + 1}/${MAX_SEALED_TOKEN_RETRIES})`); + await timeout(this.sealedTokenRetryDelayMs, token); + + const result = await this._apiService.connect({ environmentId: options.environmentId, sessionId: options.sessionId }, token); + if (result.kind !== 'token') { + // The environment went back to waking mid-wait. Keep what we have rather than + // re-entering the wake loop; the handshake watchdog covers a host that is gone. + break; + } + clientToken = result.token; + } + return clientToken; + } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts new file mode 100644 index 00000000000000..cb14dacea05415 --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { + CloudSandboxEnabledSettingId, + ICloudSandboxApiService, + type CloudSandboxConnectResult, + type ICloudSandboxClientToken, +} from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { CloudSandboxAgentHostService, MAX_SEALED_TOKEN_RETRIES } from '../../browser/cloudSandboxAgentHostService.js'; + +function clientToken(sealed: string | undefined): ICloudSandboxClientToken { + return { + access_token: 'wps-token', + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + wps_endpoint: 'wss://relay.example.com', + hub: 'hub', + subprotocol: 'json.reliable.webpubsub.azure.v1', + client_id: 'client-1', + groups: { to_host: 'to_host', to_client: 'to_client', broadcast: 'broadcast' }, + ...(sealed ? { encrypted_github_token: sealed } : {}), + } as ICloudSandboxClientToken; +} + +/** Exposes the re-mint delay and skips the relay, so the mint loop runs in isolation. */ +class TestCloudSandboxAgentHostService extends CloudSandboxAgentHostService { + protected override readonly sealedTokenRetryDelayMs = 0; + + /** The sealed token as it stood when minting finished. */ + sealedTokenAtEstablish: string | undefined; + + protected override async _establish(_options: never, address: string, clientToken: { encrypted_github_token?: string }): Promise { + this.sealedTokenAtEstablish = clientToken.encrypted_github_token; + return address; + } +} + +function createService(store: Pick<{ add(t: T): T }, 'add'>, results: readonly CloudSandboxConnectResult[]): { service: TestCloudSandboxAgentHostService; connectCalls: () => number } { + let calls = 0; + const instantiationService = store.add(new TestInstantiationService()); + + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, true); + configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true); + instantiationService.stub(IConfigurationService, configurationService); + + instantiationService.stub(ICloudSandboxApiService, new class extends mock() { + override async connect(): Promise { + // Hold the last result so a caller can keep re-minting past the scripted responses. + const result = results[Math.min(calls, results.length - 1)]; + calls++; + return result; + } + }()); + instantiationService.stub(IRemoteAgentHostService, new class extends mock() { + override readonly onDidChangeConnections = Event.None; + override readonly connections = []; + override getConnection() { return undefined; } + }()); + instantiationService.stub(IEnvironmentService, new class extends mock() { + override readonly logsHome = URI.file('/logs'); + }()); + instantiationService.stub(ILogService, new NullLogService()); + + return { + service: store.add(instantiationService.createInstance(TestCloudSandboxAgentHostService)), + connectCalls: () => calls, + }; +} + +suite('CloudSandboxAgentHostService sealed token', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('re-mints credentials until the sealed GitHub token arrives', async () => { + // A freshly provisioned environment answers `/connect` before `copilotd` has registered the + // host key Mission Control seals to, so the first credentials carry no sealed token. + const { service, connectCalls } = createService(store, [ + { kind: 'token', token: clientToken(undefined) }, + { kind: 'token', token: clientToken(undefined) }, + { kind: 'token', token: clientToken('copilot-sealed.v1.key.payload') }, + ]); + + await service.connect({ environmentId: 'env-1', name: 'Sandbox' }, CancellationToken.None); + + assert.deepStrictEqual({ calls: connectCalls(), sealed: service.sealedTokenAtEstablish }, { + calls: 3, + sealed: 'copilot-sealed.v1.key.payload', + }); + }); + + test('gives up re-minting and connects anyway, since a host may never seal one', async () => { + // Refusing to connect would be worse than a session that cannot reach GitHub APIs. + const { service, connectCalls } = createService(store, [ + { kind: 'token', token: clientToken(undefined) }, + ]); + + await service.connect({ environmentId: 'env-1', name: 'Sandbox' }, CancellationToken.None); + + // Bounded rather than an unbounded loop, and the connection still proceeds without a + // sealed token. The initial mint plus one re-mint per retry. + assert.deepStrictEqual({ calls: connectCalls(), sealed: service.sealedTokenAtEstablish }, { + calls: MAX_SEALED_TOKEN_RETRIES + 1, + sealed: undefined, + }); + }); + + test('does not re-mint when the first credentials already carry a sealed token', async () => { + const { service, connectCalls } = createService(store, [ + { kind: 'token', token: clientToken('copilot-sealed.v1.key.payload') }, + ]); + + await service.connect({ environmentId: 'env-1', name: 'Sandbox' }, CancellationToken.None); + + assert.strictEqual(connectCalls(), 1); + }); +}); From c782de04776ed567eae2b6f6aa8ebad01675f721 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 24 Aug 2026 19:53:13 -0700 Subject: [PATCH 2/3] Treat an unsealed token as missing, and survive a failed re-mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-mint loop tested truthiness while `_establish` refuses anything that is not a `copilot-sealed.v1.` envelope, so a non-empty plaintext value ended the loop and left the connection unauthenticated — the state this is meant to avoid. Both now use the same predicate. A transient failure from the re-mint also rejected `connect()`, discarding initial credentials that already work and contradicting the documented degraded fallback. Non-cancellation failures are now logged and the last token is used; only caller cancellation aborts. Adds coverage for the unsealed value, the failed re-mint, and the environment returning to waking mid-wait, which had no test. Trims comments to the repository's limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/cloudSandboxAgentHostService.ts | 51 +++++++--------- .../cloudSandboxAgentHostService.test.ts | 59 +++++++++++++++++-- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index 9cff1eedb68cb6..b2b39c4339a7ae 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationError } from '../../../../../base/common/errors.js'; +import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { timeout } from '../../../../../base/common/async.js'; @@ -21,6 +21,7 @@ import { ICloudSandboxConnectOptions, ICloudSandboxApiService, isCloudSandboxSealedToken, + type CloudSandboxConnectResult, type ICloudSandboxClientToken, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; @@ -37,17 +38,8 @@ const LOG_PREFIX = '[CloudSandboxAgentHost]'; const MAX_WAKING_RETRIES = 20; /** - * Maximum number of `/connect` re-mints while the sealed GitHub token is still missing. - * - * Mission Control seals the token to a key the host generates at startup and advertises on - * `register` and every heartbeat, so a key it has not learned yet leaves nothing to seal to. A - * freshly provisioned environment can answer `/connect` before that has propagated, yielding - * credentials with no `encrypted_github_token` and a host that rejects every session request with - * `-32007 AuthRequired`. - * - * Sized against the daemon's own timings: `copilotd` heartbeats every 30s and backs off register - * attempts up to 60s, so this spans a full register backoff and two heartbeats. A warm environment - * seals on the first mint and never enters this loop. + * Maximum number of `/connect` re-mints while the sealed GitHub token is still missing. Spans a + * full `copilotd` register backoff (60s) and two heartbeats (30s each). */ export const MAX_SEALED_TOKEN_RETRIES = 12; @@ -132,8 +124,6 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa /** * Open the relay with an already-minted token, drive the AHP handshake, and register the * connection. - * - * Protected so tests can exercise credential minting without opening a real socket. */ protected async _establish(options: ICloudSandboxConnectOptions, address: string, clientToken: ICloudSandboxClientToken, token: CancellationToken): Promise { // Mutable holder read by the transport factory: the protocol client re-invokes the factory to @@ -196,8 +186,7 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa } } } else if (!connectError) { - // Without an envelope every later request answers `-32007 AuthRequired`; say so here, - // where the cause is visible, rather than leaving only the downstream symptoms. + // Without an envelope every later request answers `-32007 AuthRequired`. this._logService.error(`${LOG_PREFIX} Mission Control returned no sealed GitHub token for ${address}; the host cannot act as the user and session requests will fail with AuthRequired.`); } @@ -254,29 +243,33 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa } /** - * Re-mint credentials until they carry a sealed GitHub token. - * - * A newly provisioned environment answers `/connect` as soon as the compute is up, which can be - * before Mission Control has learned the host's sealing key. The credentials are valid, but - * without the sealed envelope the client never sends `authenticate`, so the host answers every - * session request with `-32007 AuthRequired`. Re-minting lets the key propagate. - * - * Returns the last credentials either way: an environment may legitimately never seal one, and a - * session that cannot reach GitHub APIs beats refusing to connect. {@link _establish} logs that. + * Re-mint credentials until they carry a sealed GitHub token, which a freshly provisioned + * environment can omit until Mission Control learns the host's sealing key. Returns the last + * credentials either way, since an environment may legitimately never seal one. */ private async _awaitSealedToken(options: ICloudSandboxConnectOptions, minted: ICloudSandboxClientToken, token: CancellationToken): Promise { let clientToken = minted; - for (let attempt = 0; attempt < MAX_SEALED_TOKEN_RETRIES && !clientToken.encrypted_github_token; attempt++) { + // Match what `_establish` accepts: an unsealed value would wrongly end the loop. + for (let attempt = 0; attempt < MAX_SEALED_TOKEN_RETRIES && !isCloudSandboxSealedToken(clientToken.encrypted_github_token); attempt++) { if (token.isCancellationRequested) { throw new CancellationError(); } this._logService.info(`${LOG_PREFIX} Environment ${options.environmentId} has no sealed GitHub token yet; re-minting in ${this.sealedTokenRetryDelayMs}ms (attempt ${attempt + 1}/${MAX_SEALED_TOKEN_RETRIES})`); await timeout(this.sealedTokenRetryDelayMs, token); - const result = await this._apiService.connect({ environmentId: options.environmentId, sessionId: options.sessionId }, token); + let result: CloudSandboxConnectResult; + try { + result = await this._apiService.connect({ environmentId: options.environmentId, sessionId: options.sessionId }, token); + } catch (err) { + if (isCancellationError(err) || token.isCancellationRequested) { + throw err; + } + // The initial mint still works, so degrade rather than discard it. + this._logService.warn(`${LOG_PREFIX} Re-mint for ${options.environmentId} failed; continuing without a sealed token`, err); + break; + } if (result.kind !== 'token') { - // The environment went back to waking mid-wait. Keep what we have rather than - // re-entering the wake loop; the handshake watchdog covers a host that is gone. + // Went back to waking mid-wait; the handshake watchdog covers a host that is gone. break; } clientToken = result.token; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts index cb14dacea05415..e0eae97184c6f8 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts @@ -11,6 +11,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { CloudSandboxEnabledSettingId, + cloudSandboxAddress, ICloudSandboxApiService, type CloudSandboxConnectResult, type ICloudSandboxClientToken, @@ -49,7 +50,9 @@ class TestCloudSandboxAgentHostService extends CloudSandboxAgentHostService { } } -function createService(store: Pick<{ add(t: T): T }, 'add'>, results: readonly CloudSandboxConnectResult[]): { service: TestCloudSandboxAgentHostService; connectCalls: () => number } { +type ScriptedConnectResult = CloudSandboxConnectResult | Error; + +function createService(store: Pick<{ add(t: T): T }, 'add'>, results: readonly ScriptedConnectResult[]): { service: TestCloudSandboxAgentHostService; connectCalls: () => number } { let calls = 0; const instantiationService = store.add(new TestInstantiationService()); @@ -63,6 +66,9 @@ function createService(store: Pick<{ add(t: T): T // Hold the last result so a caller can keep re-minting past the scripted responses. const result = results[Math.min(calls, results.length - 1)]; calls++; + if (result instanceof Error) { + throw result; + } return result; } }()); @@ -87,8 +93,7 @@ suite('CloudSandboxAgentHostService sealed token', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); test('re-mints credentials until the sealed GitHub token arrives', async () => { - // A freshly provisioned environment answers `/connect` before `copilotd` has registered the - // host key Mission Control seals to, so the first credentials carry no sealed token. + // A fresh environment answers `/connect` before `copilotd` registers its sealing key. const { service, connectCalls } = createService(store, [ { kind: 'token', token: clientToken(undefined) }, { kind: 'token', token: clientToken(undefined) }, @@ -111,8 +116,7 @@ suite('CloudSandboxAgentHostService sealed token', () => { await service.connect({ environmentId: 'env-1', name: 'Sandbox' }, CancellationToken.None); - // Bounded rather than an unbounded loop, and the connection still proceeds without a - // sealed token. The initial mint plus one re-mint per retry. + // Bounded, and the connection still proceeds unsealed. Initial mint plus one per retry. assert.deepStrictEqual({ calls: connectCalls(), sealed: service.sealedTokenAtEstablish }, { calls: MAX_SEALED_TOKEN_RETRIES + 1, sealed: undefined, @@ -128,4 +132,49 @@ suite('CloudSandboxAgentHostService sealed token', () => { assert.strictEqual(connectCalls(), 1); }); + + test('keeps re-minting when the value is present but not a sealed envelope', async () => { + // A plaintext bearer is refused when forwarding, so accepting it here would skip re-minting. + const { service, connectCalls } = createService(store, [ + { kind: 'token', token: clientToken('ghu_plaintext') }, + { kind: 'token', token: clientToken('copilot-sealed.v1.key.payload') }, + ]); + + await service.connect({ environmentId: 'env-1', name: 'Sandbox' }, CancellationToken.None); + + assert.deepStrictEqual({ calls: connectCalls(), sealed: service.sealedTokenAtEstablish }, { + calls: 2, + sealed: 'copilot-sealed.v1.key.payload', + }); + }); + + test('connects with the initial credentials when a re-mint fails', async () => { + // A transient failure while chasing the seal must not discard credentials that work. + const { service } = createService(store, [ + { kind: 'token', token: clientToken(undefined) }, + new Error('network blip'), + ]); + + const address = await service.connect({ environmentId: 'env-1', name: 'Sandbox' }, CancellationToken.None); + + assert.deepStrictEqual({ address, sealed: service.sealedTokenAtEstablish }, { + address: cloudSandboxAddress('env-1'), + sealed: undefined, + }); + }); + + test('stops re-minting when the environment goes back to waking', async () => { + // Re-entering the wake loop would stack two waits; the handshake watchdog covers this. + const { service, connectCalls } = createService(store, [ + { kind: 'token', token: clientToken(undefined) }, + { kind: 'waking', waking: { retryAfterSeconds: 5 } as never }, + ]); + + await service.connect({ environmentId: 'env-1', name: 'Sandbox' }, CancellationToken.None); + + assert.deepStrictEqual({ calls: connectCalls(), sealed: service.sealedTokenAtEstablish }, { + calls: 2, + sealed: undefined, + }); + }); }); From 750620674d7cec644cf1f0dd129a4f43dc75c187 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 24 Aug 2026 20:07:15 -0700 Subject: [PATCH 3/3] Describe the retry bound without naming backend internals The constant documented the exact backend retry and heartbeat intervals it was sized against, and nearby comments described how credentials become complete. Say what the client does and why the bound is derived rather than guessed, without publishing service internals in shipped source. Behaviour is unchanged; comments and one log message only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/cloudSandboxAgentHostService.ts | 14 +++++++------- .../browser/cloudSandboxAgentHostService.test.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index b2b39c4339a7ae..9fb0a8c27bd40a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -38,12 +38,12 @@ const LOG_PREFIX = '[CloudSandboxAgentHost]'; const MAX_WAKING_RETRIES = 20; /** - * Maximum number of `/connect` re-mints while the sealed GitHub token is still missing. Spans a - * full `copilotd` register backoff (60s) and two heartbeats (30s each). + * Maximum number of `/connect` re-mints while the sealed token is missing, sized to cover the + * backend's own registration retry cycle. */ export const MAX_SEALED_TOKEN_RETRIES = 12; -/** Delay between `/connect` re-mints while waiting for the host key to propagate. */ +/** Delay between `/connect` re-mints while waiting for complete credentials. */ const SEALED_TOKEN_RETRY_DELAY_MS = 5_000; /** @@ -187,7 +187,7 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa } } else if (!connectError) { // Without an envelope every later request answers `-32007 AuthRequired`. - this._logService.error(`${LOG_PREFIX} Mission Control returned no sealed GitHub token for ${address}; the host cannot act as the user and session requests will fail with AuthRequired.`); + this._logService.error(`${LOG_PREFIX} Mission Control returned no sealed token for ${address}; this session will not be able to make authenticated requests.`); } try { @@ -243,9 +243,9 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa } /** - * Re-mint credentials until they carry a sealed GitHub token, which a freshly provisioned - * environment can omit until Mission Control learns the host's sealing key. Returns the last - * credentials either way, since an environment may legitimately never seal one. + * Re-mint credentials until they carry a sealed token, which a freshly provisioned environment + * can omit for a short window after it comes up. Returns the last credentials either way, since + * an environment may legitimately never seal one. */ private async _awaitSealedToken(options: ICloudSandboxConnectOptions, minted: ICloudSandboxClientToken, token: CancellationToken): Promise { let clientToken = minted; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts index e0eae97184c6f8..04d223ea19bbd5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostService.test.ts @@ -93,7 +93,7 @@ suite('CloudSandboxAgentHostService sealed token', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); test('re-mints credentials until the sealed GitHub token arrives', async () => { - // A fresh environment answers `/connect` before `copilotd` registers its sealing key. + // A fresh environment can answer `/connect` before its credentials are complete. const { service, connectCalls } = createService(store, [ { kind: 'token', token: clientToken(undefined) }, { kind: 'token', token: clientToken(undefined) },