Repository: stateful/vscode-runme
Pull request #1759 (2024-10-25 19:11:52)

Adds authentication support for the extension in playground environments
Adds authentication support for the extension in playground environments, integrating cloud token validation and enabling token retrieval from callback URLs dispatched by the cloud. Key changes include:

- **Auth Token Handling:** Simplified by removing unused refresh token logic, as the access-token has an extended lifespan.
- **Load from Secrets:** An instance method now checks tokens against playground secrets, using the cloud endpoint for validation.
- **Callback Integration:** Supports token recovery from cloud callback URLs, enhancing seamless access when the user is already Authenticated in the Cloud.


Patch:
diff --git a/.gitignore b/.gitignore
index 9f88bb62..4093c641 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ examples/.vscode
 assets
 .venv
 **/*-01*.md
+secrets
diff --git a/.vscodeignore b/.vscodeignore
index 6a7e2b00..862d3386 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -18,3 +18,4 @@ tests
 coverage
 dagger
 dagger.json
+secrets
diff --git a/package-lock.json b/package-lock.json
index c93caeac..bfa16121 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "runme",
-  "version": "3.9.1-edge.1",
+  "version": "3.9.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "runme",
-      "version": "3.9.1-edge.1",
+      "version": "3.9.1",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -47,6 +47,7 @@
         "got": "^11.8.2",
         "graphql": "^16.8.0",
         "jsonc-parser": "^3.2.1",
+        "jsonwebtoken": "^9.0.2",
         "lit": "^3.2.1",
         "octokit": "^4.0.2",
         "simple-git": "^3.27.0",
@@ -63,6 +64,7 @@
         "@graphql-codegen/client-preset": "^4.4.0",
         "@graphql-codegen/typescript": "4.1.0",
         "@octokit/rest": "^19.0.13",
+        "@types/jsonwebtoken": "^9.0.7",
         "@types/node": "^20.17.2",
         "@types/node-fetch": "^2.6.11",
         "@types/semver": "^7.5.8",
@@ -7017,6 +7019,16 @@
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
       "dev": true
     },
+    "node_modules/@types/jsonwebtoken": {
+      "version": "9.0.7",
+      "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz",
+      "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/keyv": {
       "version": "3.1.4",
       "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
@@ -16303,7 +16315,7 @@
       "version": "9.0.2",
       "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
       "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
-      "dev": true,
+      "license": "MIT",
       "dependencies": {
         "jws": "^3.2.2",
         "lodash.includes": "^4.3.0",
@@ -16325,7 +16337,6 @@
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
       "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
-      "dev": true,
       "dependencies": {
         "buffer-equal-constant-time": "1.0.1",
         "ecdsa-sig-formatter": "1.0.11",
@@ -16336,7 +16347,6 @@
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
       "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
-      "dev": true,
       "dependencies": {
         "jwa": "^1.4.1",
         "safe-buffer": "^5.0.1"
@@ -16784,14 +16794,12 @@
     "node_modules/lodash.includes": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
-      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
-      "dev": true
+      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
     },
     "node_modules/lodash.isboolean": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
-      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
-      "dev": true
+      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
     },
     "node_modules/lodash.isequal": {
       "version": "4.5.0",
@@ -16802,26 +16810,22 @@
     "node_modules/lodash.isinteger": {
       "version": "4.0.4",
       "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
-      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
-      "dev": true
+      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
     },
     "node_modules/lodash.isnumber": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
-      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
-      "dev": true
+      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
     },
     "node_modules/lodash.isplainobject": {
       "version": "4.0.6",
       "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
-      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
-      "dev": true
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
     },
     "node_modules/lodash.isstring": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
-      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
-      "dev": true
+      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
     },
     "node_modules/lodash.merge": {
       "version": "4.6.2",
@@ -16832,8 +16836,7 @@
     "node_modules/lodash.once": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
-      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
-      "dev": true
+      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
     },
     "node_modules/lodash.pickby": {
       "version": "4.6.0",
diff --git a/package.json b/package.json
index 9bee5d5f..d711e90b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "runme",
-  "version": "3.9.1-edge.1",
+  "version": "3.9.1",
   "publisher": "stateful",
   "displayName": "Runme Notebooks for DevOps",
   "description": "Notebooks for DevOps written in Markdown. Runnable, configurable commands/code and embedded terminals. Integrated with cloud consoles, containers and pipelines.",
@@ -966,16 +966,25 @@
             "default": true,
             "markdownDescription": "If set to `true` enables Stateful Authentication Provider"
           },
-          "runme.aiBaseURL": {
-            "type": "string",
-            "default": "http://localhost:8877/api",
-            "description": "The base URL of the AI service."
-          },
           "runme.app.docsUrl": {
             "type": "string",
             "scope": "window",
             "default": "https://docs.runme.dev",
             "markdownDescription": "Documentation Base URL"
+          },
+          "runme.app.authTokenPath": {
+            "type": "string",
+            "markdownDescription": "Specifies the path to an auth token file to bootstrap a Stateful auth session"
+          },
+          "runme.app.deleteAuthToken": {
+            "type": "boolean",
+            "default": true,
+            "markdownDescription": "If set to `true`, the auth token file will be deleted after the session ends"
+          },
+          "runme.aiBaseURL": {
+            "type": "string",
+            "default": "http://localhost:8877/api",
+            "description": "The base URL of the AI service."
           }
         }
       }
@@ -1226,6 +1235,7 @@
     "@graphql-codegen/client-preset": "^4.4.0",
     "@graphql-codegen/typescript": "4.1.0",
     "@octokit/rest": "^19.0.13",
+    "@types/jsonwebtoken": "^9.0.7",
     "@types/node": "^20.17.2",
     "@types/node-fetch": "^2.6.11",
     "@types/semver": "^7.5.8",
@@ -1307,6 +1317,7 @@
     "got": "^11.8.2",
     "graphql": "^16.8.0",
     "jsonc-parser": "^3.2.1",
+    "jsonwebtoken": "^9.0.2",
     "lit": "^3.2.1",
     "octokit": "^4.0.2",
     "simple-git": "^3.27.0",
diff --git a/src/extension/ai/converters.ts b/src/extension/ai/converters.ts
index 39caa586..349e2e56 100644
--- a/src/extension/ai/converters.ts
+++ b/src/extension/ai/converters.ts
@@ -9,6 +9,31 @@ import { ServerLifecycleIdentity, getServerConfigurationValue } from '../../util
 import { Serializer } from '../../types'
 import * as serializerTypes from '../grpc/serializerTypes'
 import * as serializer from '../serializer'
+import { Kernel } from '../kernel'
+
+import * as protos from './protos'
+
+// Converter provides converstion routines from vscode data types to protocol buffer types.
+// It is a class because in order to handle the conversion we need to keep track of the kernel
+// because we need to add execution information to the cells before serializing.
+export class Converter {
+  kernel: Kernel
+  constructor(kernel: Kernel) {
+    this.kernel = kernel
+  }
+
+  // notebokDataToProto converts a VSCode NotebookData to a RunMe Notebook proto.
+  // It adds execution information to the cells before converting.
+  public async notebookDataToProto(notebookData: vscode.NotebookData): Promise<parser_pb.Notebook> {
+    // We need to add the execution info to the cells so that the AI model can use that information.
+    const cellDataWithExec = await serializer.SerializerBase.addExecInfo(notebookData, this.kernel)
+    let notebookDataWithExec = new vscode.NotebookData(cellDataWithExec)
+    // marshalNotebook returns a protocol buffer using the ts client library from buf we need to
+    // convert it to es
+    let notebookProto = serializer.GrpcSerializer.marshalNotebook(notebookDataWithExec)
+    return protos.notebookTSToES(notebookProto)
+  }
+}
 
 // cellToCellData converts a NotebookCell to a NotebookCellData.
 // NotebookCell is an interface used by the editor.
diff --git a/src/extension/ai/events.ts b/src/extension/ai/events.ts
index 71b9e4e5..84a5dab2 100644
--- a/src/extension/ai/events.ts
+++ b/src/extension/ai/events.ts
@@ -8,6 +8,7 @@ import {
 } from '@buf/jlewi_foyle.bufbuild_es/foyle/v1alpha1/agent_pb'
 import * as vscode from 'vscode'
 import { ulid } from 'ulidx'
+import { Subject, Subscription, from, map, mergeMap } from 'rxjs'
 
 import { RUNME_CELL_ID } from '../constants'
 import getLogger from '../logger'
@@ -18,21 +19,48 @@ import * as converters from './converters'
 // Interface for the event reporter
 // This allows us to swap in a null op logger when AI isn't enabled
 export interface IEventReporter {
-  reportExecution(cell: vscode.NotebookCell, status: boolean): Promise<void>
-  reportEvents(events: LogEvent[]): Promise<void>
+  reportExecution(cell: vscode.NotebookCell, status: boolean): void
+  reportEvents(events: LogEvent[]): void
 }
 
 // EventReporter handles reporting events to the AI service
-export class EventReporter implements IEventReporter {
+export class EventReporter implements vscode.Disposable, IEventReporter {
   client: PromiseClient<typeof AIService>
   log: ReturnType<typeof getLogger>
+  queue: Subject<LogEvent[]> = new Subject()
+  subscription: Subscription
 
   constructor(client: PromiseClient<typeof AIService>) {
     this.client = client
     this.log = getLogger('AIEventReporter')
+
+    this.subscription = this.queue
+      .pipe(
+        map((events) => {
+          for (const event of events) {
+            if (event.eventId === '') {
+              event.eventId = ulid()
+            }
+          }
+          return new LogEventsRequest({ events })
+        }),
+        mergeMap((req) => {
+          return from(
+            this.client.logEvents(req).catch((e) => {
+              // handle error local to the promise, to avoid stream termination
+              this.log.error(`Failed to log event; error: ${e}`)
+            }),
+          )
+        }),
+      )
+      .subscribe() // just subscribe, ignore responses
+  }
+
+  dispose(): void {
+    this.subscription.unsubscribe()
   }
 
-  async reportExecution(cell: vscode.NotebookCell, executionSuccess: boolean) {
+  reportExecution(cell: vscode.NotebookCell, executionSuccess: boolean) {
     const contextCells: vscode.NotebookCell[] = []
 
     // Include some previous cells as context.
@@ -68,27 +96,18 @@ export class EventReporter implements IEventReporter {
     return this.reportEvents([event])
   }
 
-  async reportEvents(events: LogEvent[]) {
-    const req = new LogEventsRequest()
-    req.events = events
-    for (const event of events) {
-      if (event.eventId === '') {
-        event.eventId = ulid()
-      }
-    }
-    await this.client.logEvents(req).catch((e) => {
-      this.log.error(`Failed to log event; error: ${e}`)
-    })
+  reportEvents(events: LogEvent[]) {
+    this.queue.next(events)
   }
 }
 
 // NullOpEventReporter is a null op implementation of the event reporter
 export class NullOpEventReporter implements IEventReporter {
-  async reportExecution(_cell: vscode.NotebookCell, _status: boolean) {
+  reportExecution(_cell: vscode.NotebookCell, _status: boolean) {
     // Do nothing
   }
 
-  async reportEvents(_events: LogEvent[]) {
+  reportEvents(_events: LogEvent[]) {
     // Do nothing
   }
 }
diff --git a/src/extension/ai/generate.ts b/src/extension/ai/generate.ts
index a5cb31eb..f1fa5eab 100644
--- a/src/extension/ai/generate.ts
+++ b/src/extension/ai/generate.ts
@@ -6,9 +6,9 @@ import {
   GenerateCellsResponse,
 } from '@buf/jlewi_foyle.bufbuild_es/foyle/v1alpha1/agent_pb'
 
-import * as serializer from '../serializer'
 import getLogger from '../logger'
 
+import { Converter } from './converters'
 import * as protos from './protos'
 import * as converters from './converters'
 const log = getLogger('AIGenerate')
@@ -17,9 +17,11 @@ const log = getLogger('AIGenerate')
 // It generates a single completion
 export class CompletionGenerator {
   client: PromiseClient<typeof AIService>
+  converter: Converter
 
-  constructor(client: PromiseClient<typeof AIService>) {
+  constructor(client: PromiseClient<typeof AIService>, converter: Converter) {
     this.client = client
+    this.converter = converter
   }
 
   public generateCompletion = async () => {
@@ -43,10 +45,10 @@ export class CompletionGenerator {
     let cellData = editor?.notebook.getCells().map((cell) => converters.cellToCellData(cell))
     let notebookData = new vscode.NotebookData(cellData)
 
-    let notebookProto = serializer.GrpcSerializer.marshalNotebook(notebookData)
+    let notebookProto = await this.converter.notebookDataToProto(notebookData)
 
     const req = new GenerateCellsRequest()
-    req.notebook = protos.notebookTSToES(notebookProto)
+    req.notebook = notebookProto
     req.selectedIndex = lastSelectedCell
 
     this.client
diff --git a/src/extension/ai/ghost.ts b/src/extension/ai/ghost.ts
index 3ff3c8c8..203a9b61 100644
--- a/src/extension/ai/ghost.ts
+++ b/src/extension/ai/ghost.ts
@@ -3,7 +3,6 @@ import * as agent_pb from '@buf/jlewi_foyle.bufbuild_es/foyle/v1alpha1/agent_pb'
 import { StreamGenerateRequest_Trigger } from '@buf/jlewi_foyle.bufbuild_es/foyle/v1alpha1/agent_pb'
 
 import getLogger from '../logger'
-import * as serializer from '../serializer'
 import { RUNME_CELL_ID } from '../constants'
 
 import * as converters from './converters'
@@ -47,15 +46,16 @@ const ghostDecoration = vscode.window.createTextEditorDecorationType({
 // the cell contents have changed.
 export class GhostCellGenerator implements stream.CompletionHandlers {
   private notebookState: Map<vscode.Uri, NotebookState>
-
+  private converter: converters.Converter
   // contextID is the ID of the context we are generating completions for.
   // It is used to detect whether a completion response is stale and should be
   // discarded because the context has changed.
 
-  constructor() {
+  constructor(converter: converters.Converter) {
     this.notebookState = new Map<vscode.Uri, NotebookState>()
     // Generate a random context ID. This should be unnecessary because presumable the event to change
     // the active cell will be sent before any requests are sent but it doesn't hurt to be safe.
+    this.converter = converter
   }
 
   // Updated method to check and initialize notebook state
@@ -70,10 +70,10 @@ export class GhostCellGenerator implements stream.CompletionHandlers {
   // This is a stateful transformation because we need to decide whether to send the full document or
   // the incremental changes.  It will return a null request if the event should be ignored or if there
   // is an error preventing it from computing a proper request.
-  buildRequest(
+  async buildRequest(
     cellChangeEvent: stream.CellChangeEvent,
     firstRequest: boolean,
-  ): agent_pb.StreamGenerateRequest | null {
+  ): Promise<agent_pb.StreamGenerateRequest | null> {
     // TODO(jeremy): Is there a more efficient way to find the cell and notebook?
     // Can we cache it in the class? Since we keep track of notebooks in NotebookState
     // Is there a way we can go from the URI of the cell to the URI of the notebook directly
@@ -86,7 +86,7 @@ export class GhostCellGenerator implements stream.CompletionHandlers {
     if (notebook === undefined) {
       log.error(`notebook for cell ${cellChangeEvent.notebookUri} NOT found`)
       // TODO(jermey): Should we change the return type to be nullable?
-      return null
+      return Promise.resolve(null)
     }
 
     // Get the notebook state; this will initialize it if this is the first time we
@@ -121,39 +121,34 @@ export class GhostCellGenerator implements stream.CompletionHandlers {
       let cellData = notebook.getCells().map((cell) => converters.cellToCellData(cell))
       let notebookData = new vscode.NotebookData(cellData)
 
-      let notebookProto = serializer.GrpcSerializer.marshalNotebook(notebookData)
+      let notebookProto = await this.converter.notebookDataToProto(notebookData)
       let request = new agent_pb.StreamGenerateRequest({
         contextId: SessionManager.getManager().getID(),
         request: {
           case: 'fullContext',
           value: new agent_pb.FullContext({
-            notebook: protos.notebookTSToES(notebookProto),
+            notebook: notebookProto,
             selected: matchedCell.index,
             notebookUri: notebook.uri.toString(),
           }),
         },
-        trigger: cellChangeEvent.trigger,
       })
-
       return request
     } else {
       let cellData = converters.cellToCellData(matchedCell)
       let notebookData = new vscode.NotebookData([cellData])
 
-      let notebookProto = serializer.GrpcSerializer.marshalNotebook(notebookData)
-      let notebook = protos.notebookTSToES(notebookProto)
       // Generate an update request
+      let notebookProto = await this.converter.notebookDataToProto(notebookData)
       let request = new agent_pb.StreamGenerateRequest({
         contextId: SessionManager.getManager().getID(),
         request: {
           case: 'update',
           value: new agent_pb.UpdateContext({
-            cell: notebook.cells[0],
+            cell: notebookProto.cells[0],
           }),
         },
-        trigger: cellChangeEvent.trigger,
       })
-
       return request
     }
   }
@@ -321,7 +316,7 @@ export class CellChangeEventGenerator {
     this.streamCreator = streamCreator
   }
 
-  handleOnDidChangeNotebookCell = (event: vscode.TextDocumentChangeEvent) => {
+  handleOnDidChangeNotebookCell = async (event: vscode.TextDocumentChangeEvent) => {
     if (![vsCodeCellScheme].includes(event.document.uri.scheme)) {
       return
     }
@@ -347,7 +342,7 @@ export class CellChangeEventGenerator {
       return
     }
 
-    this.streamCreator.handleEvent(
+    await this.streamCreator.handleEvent(
       new stream.CellChangeEvent(
         notebook.uri.toString(),
         matchedCell.index,
@@ -384,7 +379,13 @@ export class CellChangeEventGenerator {
   }
 
   handleOnDidChangeNotebookDocument = (event: vscode.NotebookDocumentChangeEvent) => {
-    event.cellChanges.forEach((change) => {
+    // N.B. For non-interactive cells this will trigger each time the output is updated.
+    // For interactive cells this doesn't appear to trigger each time the cell output is updated.
+    // For example, if you have a long running command (e.g. a bash for loop with a sleep that
+    // echos a message on each iteration) then this won't trigger on each iteration for
+    // an interactive cell but will for non-interactive.
+    event.cellChanges.forEach(async (change) => {
+      log.info(`handleOnDidChangeNotebookDocument: change: ${change}`)
       if (change.outputs !== undefined) {
         // If outputs change then we want to trigger completions.
 
@@ -399,7 +400,8 @@ export class CellChangeEventGenerator {
         // In particular its possible that the cell that changed is not the active cell. Therefore
         // we may not want to generate completions for it. For example, you can have multiple cells
         // running. So in principle the active cell could be different from the cell that changed.
-        this.streamCreator.handleEvent(
+        //
+        await this.streamCreator.handleEvent(
           new stream.CellChangeEvent(
             change.cell.notebook.uri.toString(),
             change.cell.index,
diff --git a/src/extension/ai/manager.ts b/src/extension/ai/manager.ts
index 41ff8421..4943542f 100644
--- a/src/extension/ai/manager.ts
+++ b/src/extension/ai/manager.ts
@@ -3,8 +3,10 @@ import { createPromiseClient, PromiseClient, Transport } from '@connectrpc/conne
 import { createConnectTransport } from '@connectrpc/connect-node'
 import { AIService } from '@buf/jlewi_foyle.connectrpc_es/foyle/v1alpha1/agent_connect'
 
+import { Kernel } from '../kernel'
 import getLogger from '../logger'
 
+import { Converter } from './converters'
 import * as ghost from './ghost'
 import * as stream from './stream'
 import * as generate from './generate'
@@ -12,25 +14,31 @@ import * as events from './events'
 import { SessionManager } from './sessions'
 
 // AIManager is a class that manages the AI services.
-export class AIManager {
+export class AIManager implements vscode.Disposable {
   log: ReturnType<typeof getLogger>
 
   subscriptions: vscode.Disposable[] = []
   client: PromiseClient<typeof AIService>
   completionGenerator: generate.CompletionGenerator
+  converter: Converter
 
-  constructor() {
+  constructor(kernel: Kernel) {
     this.log = getLogger('AIManager')
     this.log.info('AI: Initializing AI Manager')
     const config = vscode.workspace.getConfiguration('runme.experiments')
     const autoComplete = config.get<boolean>('aiAutoCell', false)
     this.client = this.createAIClient()
-    this.completionGenerator = new generate.CompletionGenerator(this.client)
+
+    this.converter = new Converter(kernel)
+    this.completionGenerator = new generate.CompletionGenerator(this.client, this.converter)
     if (autoComplete) {
       this.registerGhostCellEvents()
 
+      const reporter = new events.EventReporter(this.client)
+      this.subscriptions.push(reporter)
+
       // Update the global event reporter to use the AI service
-      events.setEventReporter(new events.EventReporter(this.client))
+      events.setEventReporter(reporter)
     }
   }
 
@@ -47,7 +55,7 @@ export class AIManager {
   // as well as when cells change. This is used to create ghost cells.
   registerGhostCellEvents() {
     this.log.info('AI: Enabling AutoCell Generation')
-    let cellGenerator = new ghost.GhostCellGenerator()
+    let cellGenerator = new ghost.GhostCellGenerator(this.converter)
 
     // Create a stream creator. The StreamCreator is a class that effectively windows events
     // and turns each window into an AsyncIterable of streaming requests.
@@ -74,6 +82,8 @@ export class AIManager {
       vscode.window.onDidChangeActiveTextEditor(cellGenerator.handleOnDidChangeActiveTextEditor),
     )
 
+    // We use onDidChangeNotebookDocument to listen for changes to outputs.
+    // We use this to trigger updates in response to a cell's output being updated.
     this.subscriptions.push(
       vscode.workspace.onDidChangeNotebookDocument(
         eventGenerator.handleOnDidChangeNotebookDocument,
diff --git a/src/extension/ai/stream.ts b/src/extension/ai/stream.ts
index 44a53016..9df45a03 100644
--- a/src/extension/ai/stream.ts
+++ b/src/extension/ai/stream.ts
@@ -21,7 +21,7 @@ export interface CompletionHandlers {
   buildRequest: (
     cellChangeEvent: CellChangeEvent,
     firstRequest: boolean,
-  ) => StreamGenerateRequest | null
+  ) => Promise<StreamGenerateRequest | null>
 
   // processResponse is a function that processes a StreamGenerateResponse
   processResponse: (response: StreamGenerateResponse) => void
@@ -75,7 +75,7 @@ export class StreamCreator {
   // handleEvent processes a request
   // n.b we use arror function definition to ensure this gets properly bound
   // see https://www.typescriptlang.org/docs/handbook/2/classes.html#this-at-runtime-in-classes
-  handleEvent = (event: CellChangeEvent): void => {
+  handleEvent = async (event: CellChangeEvent): Promise<void> => {
     // We need to generate a new request
     let firstRequest = false
     if (this.lastIterator === undefined || this.lastIterator === null) {
@@ -83,7 +83,7 @@ export class StreamCreator {
     }
 
     log.info('handleEvent: building request')
-    let req = this.handlers.buildRequest(event, firstRequest)
+    let req = await this.handlers.buildRequest(event, firstRequest)
 
     if (req === null) {
       log.info(`Notebook: ${event.notebookUri}; no request generated`)
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 530fe5d1..6482d527 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -18,7 +18,6 @@ import { NotebookUiEvent, Serializer, SyncSchema, FeatureName } from '../types'
 import {
   getDocsUrlFor,
   getForceNewWindowConfig,
-  getRunmeAppUrl,
   getServerRunnerVersion,
   getSessionOutputs,
   getServerLifecycleIdentity,
@@ -195,7 +194,7 @@ export class RunmeExtension {
     }
 
     // Start the AIManager. This will enable the AI services if the user has enabled them.
-    const aiManager = new manager.AIManager()
+    const aiManager = new manager.AIManager(kernel)
     // We need to hang onto a reference to the AIManager so it doesn't get garbage collected until the
     // extension is deactivated.
     context.subscriptions.push(aiManager)
@@ -467,23 +466,22 @@ export class RunmeExtension {
     }
 
     if (kernel.isFeatureOn(FeatureName.RequireStatefulAuth)) {
-      const forceLogin = kernel.isFeatureOn(FeatureName.ForceLogin)
-      context.subscriptions.push(new StatefulAuthProvider(context, uriHandler))
+      const statefulAuthProvider = new StatefulAuthProvider(context, uriHandler)
+      context.subscriptions.push(statefulAuthProvider)
+
+      const session = await getPlatformAuthSession(false, true)
+      let sessionFromToken = false
+      if (!session) {
+        sessionFromToken = await statefulAuthProvider.bootstrapFromToken()
+      }
+
+      const forceLogin = kernel.isFeatureOn(FeatureName.ForceLogin) || sessionFromToken
       const silent = forceLogin ? undefined : true
 
       getPlatformAuthSession(forceLogin, silent)
         .then((session) => {
           if (session) {
-            const openDashboardStr = 'Open Dashboard'
-            window
-              .showInformationMessage('Logged into the Stateful Platform', openDashboardStr)
-              .then((answer) => {
-                if (answer === openDashboardStr) {
-                  const dashboardUri = getRunmeAppUrl(['app'])
-                  const uri = Uri.parse(dashboardUri)
-                  env.openExternal(uri)
-                }
-              })
+            statefulAuthProvider.showLoginNotification()
           }
         })
         .catch((error) => {
@@ -491,7 +489,7 @@ export class RunmeExtension {
           if (error instanceof Error) {
             message = error.message
           } else {
-            message = String(error)
+            message = JSON.stringify(error)
           }
 
           // https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadAuthentication.ts#L238
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index 76a2811b..a6a31b31 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -826,8 +826,7 @@ export class Kernel implements Disposable {
     }
 
     // todo(sebastian): rewrite to use non-blocking impl
-    const execCellReport = getEventReporter().reportExecution(cell, successfulCellExecution)
-    await execCellReport
+    getEventReporter().reportExecution(cell, successfulCellExecution)
 
     TelemetryReporter.sendTelemetryEvent('cell.endExecute', {
       'cell.success': successfulCellExecution?.toString(),
diff --git a/src/extension/provider/statefulAuth.ts b/src/extension/provider/statefulAuth.ts
index 7ae01363..9c640427 100644
--- a/src/extension/provider/statefulAuth.ts
+++ b/src/extension/provider/statefulAuth.ts
@@ -13,30 +13,38 @@ import {
   AuthenticationSession,
   AuthenticationProviderAuthenticationSessionsChangeEvent,
   Event,
+  workspace,
 } from 'vscode'
-import { v4 as uuid } from 'uuid'
+import { v4 as uuidv4 } from 'uuid'
 import fetch from 'node-fetch'
+import jwt, { JwtPayload } from 'jsonwebtoken'
 
-import { getRunmeAppUrl } from '../../utils/configuration'
+import { getAuthTokenPath, getDeleteAuthToken, getRunmeAppUrl } from '../../utils/configuration'
 import { AuthenticationProviders, PLATFORM_USER_SIGNED_IN } from '../../constants'
 import { RunmeUriHandler } from '../handler/uri'
 import ContextState from '../contextState'
+import getLogger from '../logger'
+
+const logger = getLogger('StatefulAuthProvider')
 
 const AUTH_NAME = 'Stateful'
 const SESSIONS_SECRET_KEY = `${AuthenticationProviders.Stateful}.sessions`
 
 interface TokenInformation {
   accessToken: string
-  refreshToken: string
   expiresIn: number
 }
 
 interface StatefulAuthSession extends AuthenticationSession {
-  refreshToken: string
   expiresIn: number
   isExpired: boolean
 }
 
+interface DecodedToken extends JwtPayload {
+  exp?: number
+  scope?: string
+}
+
 // Interface declaration for a PromiseAdapter
 interface PromiseAdapter<T, U> {
   // Function signature of the PromiseAdapter
@@ -137,25 +145,8 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
           return [session]
         }
 
-        const token = await this.getAccessToken(session.refreshToken)
-        const { accessToken, refreshToken, expiresIn } = token
-
-        if (accessToken) {
-          const updatedSession = {
-            ...session,
-            accessToken,
-            refreshToken,
-            expiresIn: secsToUnixTime(expiresIn),
-            scopes: scopes,
-          }
-
-          await this.updateSession(updatedSession)
-          ContextState.addKey(PLATFORM_USER_SIGNED_IN, true)
-          return [updatedSession]
-        } else {
-          ContextState.addKey(PLATFORM_USER_SIGNED_IN, false)
-          this.removeSession(session.id)
-        }
+        await ContextState.addKey(PLATFORM_USER_SIGNED_IN, false)
+        await this.removeSession(session.id)
       }
     } catch (e) {
       // Nothing to do
@@ -171,7 +162,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
    */
   public async createSession(scopes: string[]): Promise<StatefulAuthSession> {
     try {
-      const { accessToken, refreshToken, expiresIn } = await this.login(scopes)
+      const { accessToken, expiresIn } = await this.login(scopes)
 
       if (!accessToken) {
         throw new Error('Stateful login failure')
@@ -179,10 +170,9 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
 
       const userinfo: { name: string; email: string } = await this.getUserInfo(accessToken)
       const session: StatefulAuthSession = {
-        id: uuid(),
+        id: uuidv4(),
         expiresIn: secsToUnixTime(expiresIn),
         accessToken,
-        refreshToken,
         account: {
           label: userinfo.name,
           id: userinfo.email,
@@ -191,8 +181,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
         isExpired: false,
       }
 
-      ContextState.addKey(PLATFORM_USER_SIGNED_IN, true)
-
+      await ContextState.addKey(PLATFORM_USER_SIGNED_IN, true)
       await this.persistSessions([session], { added: [session], removed: [], changed: [] })
       return session
     } catch (e) {
@@ -201,25 +190,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
     }
   }
 
-  /**
-   * Update an existing session
-   * @param session
-   */
-  private async updateSession(session: StatefulAuthSession): Promise<void> {
-    const sessions = await this.getAllSessions()
-    if (!sessions.length) {
-      return
-    }
-
-    const sessionIdx = await this.findSessionIndex(sessions, session)
-    if (sessionIdx < 0) {
-      return
-    }
-
-    sessions[sessionIdx] = session
-    await this.persistSessions(sessions, { added: [], removed: [], changed: [session] })
-  }
-
   /**
    * Remove an existing session
    * @param sessionId
@@ -248,6 +218,99 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
     this.#disposables.forEach((d) => d.dispose())
   }
 
+  public async bootstrapFromToken(): Promise<boolean> {
+    try {
+      const authTokenUri = await this.getAuthTokenUri()
+      if (!authTokenUri) {
+        logger.info('No auth token file found, halting bootstrap from token.')
+        return false
+      }
+      const { token, payload } = await this.insecureDecode(authTokenUri)
+      const session = await this.buildSession(token, payload)
+      await this.persistSessions([session], { added: [session], removed: [], changed: [] })
+      await this.deleteAuthTokenFile(authTokenUri)
+      return true
+    } catch (error) {
+      let message
+      if (error instanceof Error) {
+        message = error.message
+      } else {
+        message = JSON.stringify(error)
+      }
+      logger.error(message)
+    }
+    return false
+  }
+
+  private async getAuthTokenUri(): Promise<Uri | undefined> {
+    const authTokenPath = getAuthTokenPath()
+    if (!authTokenPath) {
+      return
+    }
+
+    const authTokenUri = Uri.parse(authTokenPath)
+    const hasTokenFile = await workspace.fs.stat(authTokenUri).then(
+      () => true,
+      () => false,
+    )
+
+    if (!hasTokenFile) {
+      return
+    }
+
+    return authTokenUri
+  }
+
+  /**
+   * Decode a JWT token without verifying its signature.
+   */
+  private async insecureDecode(authTokenUri: Uri) {
+    const bytes = await workspace.fs.readFile(authTokenUri)
+    if (!bytes?.length) {
+      throw new Error('Failed to read token file')
+    }
+
+    const token = new TextDecoder().decode(bytes).trim()
+    const payload = jwt.decode(token) as DecodedToken
+    if (!payload) {
+      throw new Error('Failed to decode JWT token')
+    }
+
+    return { payload, token }
+  }
+
+  private async buildSession(token: string, payload: DecodedToken) {
+    if (!payload.exp || !payload.scope) {
+      throw new Error('Invalid token format, missing exp or scope')
+    }
+
+    const { name, email } = await this.getUserInfo(token)
+    if (!name || !email) {
+      throw new Error('Failed to get user info from JWT token')
+    }
+
+    const session: StatefulAuthSession = {
+      accessToken: token,
+      expiresIn: secsToUnixTime(payload.exp),
+      id: uuidv4(),
+      account: {
+        label: name,
+        id: email,
+      },
+      scopes: payload.scope!.split(' '),
+      isExpired: false,
+    }
+
+    return session
+  }
+
+  private async deleteAuthTokenFile(authTokenUri: Uri) {
+    if (getDeleteAuthToken()) {
+      logger.info(`Deleting authToken file ${authTokenUri}`)
+      await workspace.fs.delete(authTokenUri)
+    }
+  }
+
   /**
    * Log in to Stateful
    */
@@ -259,7 +322,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
         cancellable: true,
       },
       async (_, token) => {
-        const nonceId = uuid()
+        const nonceId = uuidv4()
 
         const scopeString = scopes.join(' ')
         scopes = this.getScopes(scopes)
@@ -283,6 +346,7 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
 
         const searchParams = new URLSearchParams({
           state: encodeURIComponent(callbackUri.toString(true)),
+          checkSession: 'true',
           scope: scopes.join(' '),
           codeChallengeMethod: 'S256',
           codeChallenge: codeChallenge,
@@ -345,6 +409,9 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
       const code = query.get('code')
       const stateId = query.get('state')
 
+      const accessToken = query.get('accessToken')
+      const expiresIn = query.get('expiresIn')
+
       if (!code) {
         reject(new Error('No code'))
         return
@@ -366,6 +433,11 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
         return
       }
 
+      if (accessToken && expiresIn) {
+        resolve({ accessToken, expiresIn: Number.parseInt(expiresIn) })
+        return
+      }
+
       const postData = {
         code,
         codeVerifier,
@@ -379,9 +451,12 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
         body: JSON.stringify(postData),
       })
 
-      const { accessToken, refreshToken, expiresIn } = await response.json()
+      const json = await response.json()
 
-      resolve({ accessToken, refreshToken, expiresIn })
+      resolve({
+        accessToken: json.accessToken,
+        expiresIn: json.expiresIn,
+      })
     }
 
   /**
@@ -395,7 +470,13 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
         Authorization: `Bearer ${token}`,
       },
     })
-    return await response.json()
+
+    const json = await response.json()
+    if (!response.ok) {
+      return Promise.reject(json)
+    }
+
+    return Promise.resolve(json)
   }
 
   /**
@@ -405,9 +486,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
   private getScopes(scopes: string[] = []): string[] {
     const modifiedScopes = [...scopes]
 
-    if (!modifiedScopes.includes('offline_access')) {
-      modifiedScopes.push('offline_access')
-    }
     if (!modifiedScopes.includes('openid')) {
       modifiedScopes.push('openid')
     }
@@ -421,30 +499,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
     return modifiedScopes.sort()
   }
 
-  /**
-   * Retrieve a new access token by the refresh token
-   * @param refreshToken
-   * @param clientId
-   * @returns
-   */
-  private async getAccessToken(currentRefreshToken: string): Promise<TokenInformation> {
-    const postData = {
-      refreshToken: currentRefreshToken,
-    }
-
-    const response = await fetch(`${getRunmeAppUrl(['api'])}idp-refresh-token`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-      },
-      body: JSON.stringify(postData),
-    })
-
-    const { accessToken, refreshToken, expiresIn } = await response.json()
-
-    return { accessToken, refreshToken, expiresIn }
-  }
-
   /**
    * Checks if the token is not expired, considering it invalid one hour before its actual expiration time.
    * This validation is typically used for refreshing access tokens to avoid race conditions.
@@ -480,10 +534,6 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
     }
   }
 
-  private async findSessionIndex(sessions: StatefulAuthSession[], session: StatefulAuthSession) {
-    return sessions.findIndex((s) => s.id === session.id)
-  }
-
   private async findSessionIndexById(sessions: StatefulAuthSession[], id: string) {
     return sessions.findIndex((s) => s.id === id)
   }
@@ -522,6 +572,19 @@ export class StatefulAuthProvider implements AuthenticationProvider, Disposable
 
     return { ...session, isExpired: true }
   }
+
+  showLoginNotification() {
+    const openDashboardStr = 'Open Dashboard'
+    window
+      .showInformationMessage('Logged into the Stateful Platform', openDashboardStr)
+      .then((answer) => {
+        if (answer === openDashboardStr) {
+          const dashboardUri = getRunmeAppUrl(['app'])
+          const uri = Uri.parse(dashboardUri)
+          env.openExternal(uri)
+        }
+      })
+  }
 }
 
 /**
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index 5978694a..399ecb8b 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -564,7 +564,7 @@ export function getGithubAuthSession(createIfNone: boolean = true) {
 }
 
 export async function getPlatformAuthSession(createIfNone: boolean = true, silent?: boolean) {
-  const scopes = ['profile', 'offline_access']
+  const scopes = ['profile']
   const options: AuthenticationGetSessionOptions = {}
 
   if (silent !== undefined) {
diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts
index 57a348e5..7a2b46b1 100644
--- a/src/utils/configuration.ts
+++ b/src/utils/configuration.ts
@@ -91,6 +91,8 @@ const configurationSchema = {
     loginPrompt: z.boolean().default(true),
     platformAuth: z.boolean().default(false),
     docsUrl: z.string().default(DEFAULT_DOCS_URL),
+    authTokenPath: z.string().optional(),
+    deleteAuthToken: z.boolean().default(true),
   },
 }
 
@@ -445,6 +447,14 @@ const getDocsUrlFor = (path: string): string => {
   return `${baseUrl}${path}`
 }
 
+const getAuthTokenPath = () => {
+  return getCloudConfigurationValue<string | undefined>('authTokenPath', undefined)
+}
+
+const getDeleteAuthToken = () => {
+  return getCloudConfigurationValue<boolean>('deleteAuthToken', true)
+}
+
 export {
   enableServerLogs,
   getActionsOpenViewInEditor,
@@ -477,4 +487,6 @@ export {
   getLoginPrompt,
   getDocsUrlFor,
   getDocsUrl,
+  getAuthTokenPath,
+  getDeleteAuthToken,
 }


Test patch:
diff --git a/tests/extension/provider/statefulAuth.test.ts b/tests/extension/provider/statefulAuth.test.ts
index e64029e5..5d6f9e28 100644
--- a/tests/extension/provider/statefulAuth.test.ts
+++ b/tests/extension/provider/statefulAuth.test.ts
@@ -1,7 +1,9 @@
 import * as crypto from 'node:crypto'
 
 import { expect, vi, beforeEach, describe, it } from 'vitest'
-import { Uri, ExtensionContext } from 'vscode'
+import { Uri, ExtensionContext, workspace } from 'vscode'
+import fetch from 'node-fetch'
+import jwt from 'jsonwebtoken'
 
 import { StatefulAuthProvider } from '../../../src/extension/provider/statefulAuth'
 import { RunmeUriHandler } from '../../../src/extension/handler/uri'
@@ -9,15 +11,21 @@ import { getRunmeAppUrl } from '../../../src/utils/configuration'
 
 vi.mock('vscode')
 vi.mock('vscode-telemetry')
+vi.mock('node-fetch')
 
 vi.mock('../../../src/utils/configuration', () => {
   return {
     getRunmeAppUrl: vi.fn(),
+    getDeleteAuthToken: vi.fn(() => true),
+    getAuthTokenPath: vi.fn(() => '/path/to/auth/token'),
   }
 })
 
 const contextFake: ExtensionContext = {
   extensionUri: Uri.parse('file:///Users/fakeUser/projects/vscode-runme'),
+  secrets: {
+    store: vi.fn(),
+  },
 } as any
 
 const uriHandlerFake: RunmeUriHandler = {} as any
@@ -67,3 +75,85 @@ describe('StatefulAuthProvider#sessionSecretKey', () => {
     expect(sessionSecretKey).toEqual('stateful.sessions.5d458b91cb755f8e839839dd3d1b4d597bba2c11')
   })
 })
+
+describe('StatefulAuthProvider#bootstrapFromToken', () => {
+  let provider: StatefulAuthProvider
+
+  beforeEach(() => {
+    vi.mocked(getRunmeAppUrl).mockReturnValue('https://api.stateful.dev/')
+    provider = new StatefulAuthProvider(contextFake, uriHandlerFake)
+  })
+
+  it('returns undefined if no token is provided', async () => {
+    vi.mocked(workspace.fs.stat).mockRejectedValueOnce({} as any)
+    const sessionCreated = await provider.bootstrapFromToken()
+    expect(sessionCreated).toBeFalsy()
+  })
+
+  it('returns true if token provided is valid', async () => {
+    const token = jwt.sign(
+      {
+        iss: 'Runme',
+        aud: 'XXXXXXXXXXXXXXXXXXXXXXXX',
+        iat: Math.floor(Date.now() / 1000),
+        exp: Math.floor(Date.now() / 1000) + 60 * 60,
+        sub: 'XXXXXXXXXXXX',
+        scope: 'profile email',
+      },
+      'secret',
+    )
+
+    vi.mocked(workspace.fs.stat).mockResolvedValueOnce({} as any)
+    vi.mocked(workspace.fs.readFile).mockResolvedValueOnce(Buffer.from(token))
+    vi.mocked(workspace.fs.delete).mockResolvedValueOnce()
+    vi.mocked(fetch).mockResolvedValueOnce(
+      Promise.resolve({
+        ok: true,
+        status: 200,
+        json: () =>
+          Promise.resolve({
+            email: 'john@doe.com',
+            name: 'John Doe',
+          }),
+      }) as any,
+    )
+    const spyStore = vi.spyOn(contextFake.secrets, 'store')
+    const spyDelete = vi.spyOn(workspace.fs, 'delete')
+    const sessionCreated = await provider.bootstrapFromToken()
+
+    expect(sessionCreated).toBeTruthy()
+    expect(spyStore).toHaveBeenCalledOnce()
+    expect(spyDelete).toHaveBeenCalledOnce()
+  })
+
+  it('returns true if token provided is invalid', async () => {
+    const token = jwt.sign(
+      {
+        iss: 'Runme',
+        aud: 'XXXXXXXXXXXXXXXXXXXXXXXX',
+        iat: Math.floor(Date.now() / 1000),
+        exp: Math.floor(Date.now() / 1000) + 60 * 60,
+        sub: 'XXXXXXXXXXXX',
+        scope: 'profile email',
+      },
+      'secret',
+    )
+
+    vi.mocked(workspace.fs.stat).mockResolvedValueOnce({} as any)
+    vi.mocked(workspace.fs.readFile).mockResolvedValueOnce(Buffer.from(token))
+
+    vi.mocked(fetch).mockResolvedValueOnce(
+      Promise.resolve({
+        status: 500,
+        json: () => Promise.resolve({ status: 'foo', message: 'bar' }),
+      }) as any,
+    )
+    const spyStore = vi.spyOn(contextFake.secrets, 'store')
+    const spyDelete = vi.spyOn(workspace.fs, 'delete')
+    const sessionCreated = await provider.bootstrapFromToken()
+
+    expect(sessionCreated).toBeFalsy()
+    expect(spyStore).not.toHaveBeenCalledOnce()
+    expect(spyDelete).not.toHaveBeenCalledOnce()
+  })
+})