From 6644e6c97f8de661aaa4a84c3386ac4f6ffd16ed Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 21 Apr 2026 16:18:57 +0200 Subject: [PATCH 1/2] chore: add test job in release workflow --- .github/workflows/ci.yml | 2 -- .github/workflows/release.yml | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fd29b6..898deda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: CI on: pull_request: branches: [main] - push: - branches: [main] jobs: check: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8157b5..f05aa4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,9 @@ jobs: - name: Build run: npm run build + - name: Test + run: npm test + - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From b33b419fcb8245b821bc2d6c3e222fce20611fa6 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 21 Apr 2026 16:45:17 +0200 Subject: [PATCH 2/2] feat: add OAuth Device Flow login command - src/repl/auth.ts: Device Flow implementation against Auth0 (same endpoints and client as @tigrisdata/cli) - login command in ReplSession: auth, org selection, auto-mount - Supports single org (auto-select) and multi-org (numbered prompt) - Exported via @tigrisdata/agent-shell/repl Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 74 +++++++++++++------- src/repl/auth.ts | 164 ++++++++++++++++++++++++++++++++++++++++++++ src/repl/index.ts | 2 + src/repl/session.ts | 151 +++++++++++++++++++++++++++++++--------- src/shell.ts | 6 +- src/types.ts | 2 +- 6 files changed, 337 insertions(+), 62 deletions(-) create mode 100644 src/repl/auth.ts diff --git a/README.md b/README.md index 80b58b1..2cee771 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@ # @tigrisdata/agent-shell -Persistent sandboxed storage for AI agents — a bash filesystem backed by [Tigris](https://www.tigrisdata.com/) object storage. +A virtual bash environment with a persistent filesystem backed by Tigris object storage, written in TypeScript and designed for AI agents. AI agents produce artifacts — reports, data, configs, logs. These need to go somewhere durable, shareable, and globally accessible. `@tigrisdata/agent-shell` gives agents a familiar bash interface (`cat`, `grep`, `sed`, `jq`, `awk`, pipes, redirects) where every file operation is backed by a Tigris bucket. -**What makes it a storage sandbox:** - - **Isolated** — writes stay in-memory until you explicitly flush. No partial state leaks to storage. - **Durable** — flush persists files to Tigris, globally distributed. - **Checkpointable** — take snapshots of your storage at any point. Roll back if needed. @@ -16,6 +14,8 @@ Built on [just-bash](https://github.com/vercel-labs/just-bash) for the shell eng ## Quick Start +### Programmatic Usage + ```bash npm install @tigrisdata/agent-shell ``` @@ -24,47 +24,71 @@ npm install @tigrisdata/agent-shell import { TigrisShell } from "@tigrisdata/agent-shell"; const shell = new TigrisShell({ - bucket: process.env.TIGRIS_STORAGE_BUCKET, accessKeyId: process.env.TIGRIS_STORAGE_ACCESS_KEY_ID, secretAccessKey: process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY, + bucket: process.env.TIGRIS_STORAGE_BUCKET, // optional — auto-mounts at /workspace }); await shell.exec('echo "Hello world" > greeting.txt'); await shell.exec("cat greeting.txt"); // stdout: "Hello world\n" -await shell.exec("mkdir -p reports/2026"); -await shell.exec('echo "Q1 done" > reports/2026/q1.txt'); -await shell.exec("ls reports/2026"); // stdout: "q1.txt\n" await shell.exec("cat greeting.txt | tr a-z A-Z"); // stdout: "HELLO WORLD\n" // Persist to Tigris when you're ready await shell.flush(); ``` -## Authentication +### Interactive Shell -Two auth modes are supported. At least one is required: +Launch a shell directly — no install needed: -```typescript -// Access key auth -const shell = new TigrisShell({ - accessKeyId: process.env.TIGRIS_STORAGE_ACCESS_KEY_ID, - secretAccessKey: process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY, - bucket: process.env.TIGRIS_STORAGE_BUCKET, // optional — auto-mounts at /workspace -}); +```bash +npx @tigrisdata/agent-shell +``` -// Session token auth (from OAuth login) -const shell = new TigrisShell({ - sessionToken: "...", - organizationId: "...", -}); +Authenticate with access keys: + +``` +$ configure --key tid_... --secret tsec_... +Available buckets: + my-bucket + shared-data + +Mounted my-bucket at /workspace + +$ echo "hello" > greeting.txt +$ cat greeting.txt +hello +$ ls +greeting.txt +$ flush +Flushed 1 mount(s) +``` + +Or login with your Tigris account: + +``` +$ login +Open this URL in your browser: + https://auth.storage.tigrisdata.io/activate?user_code=XKCD-1234 + +Waiting for authorization... done! +Logged in as you@example.com + +Mounted my-bucket at /workspace +``` + +You can also pass credentials as flags: + +```bash +npx @tigrisdata/agent-shell --key tid_... --secret tsec_... --bucket my-bucket ``` -## Storage Sandbox Model +## Storage Model -The shell uses an in-memory write-back cache that acts as a storage sandbox: +The shell uses an in-memory write-back cache that provides isolation: ``` -Agent writes file → cached locally (isolated) +Agent writes file → cached in memory (isolated) Agent reads file → cache hit or fetch from Tigris Agent calls flush → all changes persisted atomically ``` @@ -230,7 +254,7 @@ await bash.exec("cp /datasets/data.csv ./local.csv"); | Export | Description | | -------------- | ----------------------------------------------------------------------------------------------------- | -| `TigrisShell` | Main class — sandboxed storage shell backed by Tigris | +| `TigrisShell` | Main class — persisted storage shell backed by Tigris | | `TigrisConfig` | Config type: `{ accessKeyId?, secretAccessKey?, sessionToken?, organizationId?, bucket?, endpoint? }` | | `ShellOptions` | Shell options type: `{ cwd?, env? }` | diff --git a/src/repl/auth.ts b/src/repl/auth.ts new file mode 100644 index 0000000..5b726c8 --- /dev/null +++ b/src/repl/auth.ts @@ -0,0 +1,164 @@ +import type { ReplIO } from "./io.js"; + +const AUTH0_DOMAIN = "https://auth.storage.tigrisdata.io"; +const AUTH0_CLIENT_ID = "FKXunmhaaBZOYXjNYLIU8Fi2jIqpT7DR"; +const AUTH0_AUDIENCE = "https://tigris-os-api"; +const AUTH0_SCOPES = "openid profile email offline_access"; +const CLAIMS_NAMESPACE = "https://tigris"; +const DEFAULT_POLL_INTERVAL = 5; + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in: number; + token_type: string; +} + +export interface Organization { + id: string; + name: string; +} + +export interface LoginResult { + accessToken: string; + refreshToken?: string; + email: string; + organizations: Organization[]; +} + +/** + * Start the OAuth Device Authorization Flow. + * Returns device code info for display to the user. + */ +async function requestDeviceCode(): Promise { + const response = await fetch(`${AUTH0_DOMAIN}/oauth/device/code`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: AUTH0_CLIENT_ID, + audience: AUTH0_AUDIENCE, + scope: AUTH0_SCOPES, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Device code request failed: ${text}`); + } + + return response.json() as Promise; +} + +/** + * Poll the token endpoint until the user authorizes or the code expires. + */ +async function pollForToken(deviceCode: string, interval: number): Promise { + let pollInterval = Math.max(interval, DEFAULT_POLL_INTERVAL) * 1000; + + for (;;) { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + + const response = await fetch(`${AUTH0_DOMAIN}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: AUTH0_CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = (await response.json()) as TokenResponse & { error?: string }; + + if (data.error === "authorization_pending") { + continue; + } + if (data.error === "slow_down") { + pollInterval += 5000; // RFC 8628 §3.5: permanently increase by 5s + continue; + } + if (data.error) { + throw new Error(`Authorization failed: ${data.error}`); + } + + return data; + } +} + +/** + * Extract email from ID token (base64-decode the payload, no verification needed). + */ +function extractEmail(idToken: string): string { + const parts = idToken.split("."); + if (parts.length !== 3 || !parts[1]) { + return "unknown"; + } + + try { + // Handle base64url encoding + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const payload = JSON.parse(atob(base64)); + return payload.email ?? payload.name ?? "unknown"; + } catch { + return "unknown"; + } +} + +/** + * Fetch organizations from the userinfo endpoint. + */ +async function fetchOrganizations(accessToken: string): Promise { + const response = await fetch(`${AUTH0_DOMAIN}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + return []; + } + + const data = (await response.json()) as Record; + const claims = data[CLAIMS_NAMESPACE] as { ns?: Organization[] } | undefined; + + return claims?.ns ?? []; +} + +/** + * Run the full device authorization flow. + * Shows URL + code, waits for auth, fetches orgs. + */ +export async function deviceLogin(io: ReplIO): Promise { + io.write("Logging in to Tigris...\n"); + + const deviceCode = await requestDeviceCode(); + + io.write(`\nOpen this URL in your browser:\n`); + io.write(` ${deviceCode.verification_uri_complete}\n\n`); + io.write(`Or go to ${deviceCode.verification_uri} and enter code: ${deviceCode.user_code}\n\n`); + io.write("Waiting for authorization..."); + + const tokens = await pollForToken(deviceCode.device_code, deviceCode.interval); + + io.write(" done!\n\n"); + + const email = tokens.id_token ? extractEmail(tokens.id_token) : "unknown"; + io.write(`Logged in as ${email}\n`); + + const organizations = await fetchOrganizations(tokens.access_token); + + return { + accessToken: tokens.access_token, + ...(tokens.refresh_token !== undefined && { refreshToken: tokens.refresh_token }), + email, + organizations, + }; +} diff --git a/src/repl/index.ts b/src/repl/index.ts index 45020ef..f3a5d69 100644 --- a/src/repl/index.ts +++ b/src/repl/index.ts @@ -1,2 +1,4 @@ +export type { LoginResult, Organization } from "./auth.js"; +export { deviceLogin } from "./auth.js"; export type { ReplIO } from "./io.js"; export { ReplSession } from "./session.js"; diff --git a/src/repl/session.ts b/src/repl/session.ts index d5666ac..dc9c1d1 100644 --- a/src/repl/session.ts +++ b/src/repl/session.ts @@ -2,6 +2,7 @@ import { listBuckets } from "@tigrisdata/storage"; import type { BashExecResult } from "just-bash"; import { TigrisShell } from "../shell.js"; import type { TigrisConfig } from "../types.js"; +import { deviceLogin } from "./auth.js"; import type { ReplIO } from "./io.js"; /** @@ -13,6 +14,8 @@ import type { ReplIO } from "./io.js"; export class ReplSession { private shell: TigrisShell | null = null; private config: TigrisConfig | null = null; + private authMethod: "access-key" | "oauth" | null = null; + private email: string | undefined; private cwd: string | undefined; /** Handle a command line. Returns true if handled, false to pass to bash. */ @@ -27,6 +30,9 @@ export class ReplSession { case "clear": // Handled by the frontend (CLI/playground) return; + case "login": + await this.handleLogin(io); + return; case "configure": await this.handleConfigure(parts.slice(1), io); return; @@ -86,51 +92,119 @@ export class ReplSession { if (options.bucket) { newConfig.bucket = options.bucket; - const newShell = new TigrisShell(newConfig); - this.config = newConfig; - this.shell = newShell; - this.cwd = undefined; - io.write(`Configured. Mounted ${options.bucket} at /workspace\n`); + const mountPoint = `/${options.bucket}`; + const newShell = new TigrisShell(newConfig, { cwd: mountPoint }); + this.commitSession(newConfig, newShell, "access-key"); + io.write(`Configured. Mounted ${options.bucket} at ${mountPoint}\n`); } else { const newShell = new TigrisShell(newConfig); + await this.listAndMountBuckets(newConfig, newShell, "access-key", io); + } + } - const bucketsResult = await listBuckets({ config: newConfig }); - if ("error" in bucketsResult) { - // Commit new session even if bucket listing fails — auth is valid - this.config = newConfig; - this.shell = newShell; - this.cwd = undefined; - io.write(`Configured. Could not list buckets: ${bucketsResult.error.message}\n`); - io.write("Use 'mount ' to mount manually.\n"); - return; - } + /** Shared: list buckets, auto-mount first, commit session. */ + private async listAndMountBuckets( + newConfig: TigrisConfig, + newShell: TigrisShell, + authMethod: "access-key" | "oauth", + io: ReplIO, + ): Promise { + const bucketsResult = await listBuckets({ config: newConfig }); + if ("error" in bucketsResult) { + this.commitSession(newConfig, newShell, authMethod); + io.write(`Could not list buckets: ${bucketsResult.error.message}\n`); + io.write("Use 'mount ' to mount manually.\n"); + return; + } - // Success — commit new session - this.config = newConfig; - this.shell = newShell; - this.cwd = undefined; + this.commitSession(newConfig, newShell, authMethod); - const bucketNames = bucketsResult.data.buckets.map((b) => b.name); - if (bucketNames.length === 0) { - io.write("Configured. No buckets found.\n"); - io.write("Use 'mount ' to mount manually.\n"); + const bucketNames = bucketsResult.data.buckets.map((b) => b.name); + if (bucketNames.length === 0) { + io.write("No buckets found.\n"); + io.write("Use 'mount ' to mount manually.\n"); + return; + } + + io.write("Available buckets:\n"); + for (const name of bucketNames) { + io.write(` ${name}\n`); + } + + const first = bucketNames[0]; + if (first) { + const mountPoint = `/${first}`; + this.shell?.mount(first, mountPoint); + this.cwd = mountPoint; + io.write(`\nMounted ${first} at ${mountPoint}\n`); + } + + if (bucketNames.length > 1) { + io.write("\nTo mount additional buckets:\n"); + io.write(" mount \n"); + } + } + + /** Commit a new session — replace config, shell, reset cwd. */ + private commitSession( + config: TigrisConfig, + shell: TigrisShell, + authMethod: "access-key" | "oauth", + ): void { + this.config = config; + this.shell = shell; + this.authMethod = authMethod; + this.cwd = undefined; + } + + private async handleLogin(io: ReplIO): Promise { + try { + const result = await deviceLogin(io); + + if (result.organizations.length === 0) { + io.write("No organizations found.\n"); return; } - io.write("Available buckets:\n"); - for (const name of bucketNames) { - io.write(` ${name}\n`); + let selectedOrg = result.organizations[0]; + + if (result.organizations.length > 1) { + io.write("\nSelect organization:\n"); + for (let i = 0; i < result.organizations.length; i++) { + io.write(` ${i + 1}) ${result.organizations[i]?.name}\n`); + } + + const answer = await io.prompt("\nEnter number: "); + const index = Number.parseInt(answer, 10) - 1; + if (Number.isNaN(index) || index < 0 || index >= result.organizations.length) { + io.write("Invalid selection.\n"); + return; + } + selectedOrg = result.organizations[index]; } - const first = bucketNames[0]; - if (first) { - this.shell.mount(first, "/workspace"); - io.write(`\nMounted ${first} at /workspace\n`); + if (!selectedOrg) { + io.write("No organization selected.\n"); + return; } - if (bucketNames.length > 1) { - io.write("\nTo mount additional buckets:\n"); - io.write(" mount \n"); + io.write(`\nSelected: ${selectedOrg.name}\n`); + + const newConfig: TigrisConfig = { + sessionToken: result.accessToken, + organizationId: selectedOrg.id, + }; + + const newShell = new TigrisShell(newConfig); + this.email = result.email; + await this.listAndMountBuckets(newConfig, newShell, "oauth", io); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("fetch") || message.includes("CORS") || message.includes("network")) { + io.write("login: OAuth login is not supported in the browser.\n"); + io.write("Use 'configure' with access keys instead.\n"); + } else { + io.write(`login: ${message}\n`); } } } @@ -160,7 +234,11 @@ export class ReplSession { // Show status if no args if (!accessKeyId && !secretAccessKey && !bucket) { if (this.config) { - io.write(`Access key: ${this.config.accessKeyId?.slice(0, 8)}...\n`); + if (this.authMethod === "oauth") { + io.write(`Logged in as ${this.email ?? "unknown"}\n`); + } else { + io.write(`Access key: ${this.config.accessKeyId?.slice(0, 8)}...\n`); + } const mounts = this.shell?.listMounts() ?? []; for (const m of mounts) { io.write(` ${m.bucket} → ${m.mountPoint}\n`); @@ -287,12 +365,17 @@ export class ReplSession { private handleLogout(io: ReplIO): void { this.shell = null; this.config = null; + this.authMethod = null; + this.email = undefined; this.cwd = undefined; io.write("Logged out. All mounts removed.\n"); } private handleHelp(io: ReplIO): void { io.write("Commands:\n"); + io.write( + " login Login via browser (OAuth)\n", + ); io.write(" configure --key --secret [--bucket ] [--endpoint ]\n"); io.write(" mount Mount a bucket\n"); io.write(" mount List mounts\n"); diff --git a/src/shell.ts b/src/shell.ts index 0e5f9ff..3c8fff9 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -32,9 +32,11 @@ export class TigrisShell { this.mountableFs = new MountableFs({ base: new InMemoryFs() }); + const cwd = shellOptions?.cwd ?? "/workspace"; + // Auto-mount if bucket is provided if (config.bucket) { - this.mount(config.bucket, "/workspace"); + this.mount(config.bucket, cwd); } const commandConfig = config.bucket @@ -43,7 +45,7 @@ export class TigrisShell { this.bash = new Bash({ fs: this.mountableFs, - cwd: shellOptions?.cwd ?? "/workspace", + cwd, ...(shellOptions?.env !== undefined && { env: shellOptions.env }), customCommands: createTigrisCommands(commandConfig), }); diff --git a/src/types.ts b/src/types.ts index 792e358..d80b47a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,7 +25,7 @@ export interface TigrisConfig { * Shell-specific options. */ export interface ShellOptions { - /** Starting working directory. Defaults to /workspace. */ + /** Starting working directory and auto-mount point. Defaults to /workspace. */ cwd?: string; /** Initial environment variables for the shell. */ env?: Record;