Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
check:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- name: Build
run: npm run build

- name: Test
run: npm test

- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
74 changes: 49 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
```
Expand All @@ -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
```
Expand Down Expand Up @@ -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? }` |

Expand Down
164 changes: 164 additions & 0 deletions src/repl/auth.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceCodeResponse> {
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<DeviceCodeResponse>;
}

/**
* Poll the token endpoint until the user authorizes or the code expires.
*/
async function pollForToken(deviceCode: string, interval: number): Promise<TokenResponse> {
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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
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<Organization[]> {
const response = await fetch(`${AUTH0_DOMAIN}/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!response.ok) {
return [];
}

const data = (await response.json()) as Record<string, unknown>;
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<LoginResult> {
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,
};
}
2 changes: 2 additions & 0 deletions src/repl/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading