Skip to content

Commit 25768c1

Browse files
authored
Merge pull request #88 from AgentWorkforce/architecture-adjustments
Architecture adjustments
2 parents 0ee58fb + 420ac83 commit 25768c1

File tree

18 files changed

+2531
-108
lines changed

18 files changed

+2531
-108
lines changed

deploy/workspace/entrypoint.sh

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ log() {
66
echo "[workspace] $*"
77
}
88

9-
# Drop to workspace user if running as root
9+
# Fix volume permissions and drop to workspace user if running as root
1010
if [[ "$(id -u)" == "0" ]]; then
11+
# Fix /data and /workspace permissions before dropping privileges
12+
# Fresh Fly.io volumes are root-owned, need to chown for workspace user
13+
log "Fixing volume permissions..."
14+
chown -R workspace:workspace /data /workspace 2>/dev/null || true
15+
1116
log "Dropping privileges to workspace user..."
1217
exec gosu workspace "$0" "$@"
1318
fi
@@ -16,12 +21,54 @@ PORT="${AGENT_RELAY_DASHBOARD_PORT:-${PORT:-3888}}"
1621
export AGENT_RELAY_DASHBOARD_PORT="${PORT}"
1722
export PORT="${PORT}"
1823

24+
# ============================================================================
25+
# Per-user credential storage setup
26+
# Create user-specific HOME on persistent volume (/data)
27+
# This enables multi-user workspaces where each user has their own credentials
28+
# ============================================================================
29+
DATA_DIR="${AGENT_RELAY_DATA_DIR:-/data}"
30+
if [[ -n "${WORKSPACE_OWNER_USER_ID:-}" ]]; then
31+
USER_HOME="${DATA_DIR}/users/${WORKSPACE_OWNER_USER_ID}"
32+
log "Setting up per-user HOME at ${USER_HOME}"
33+
mkdir -p "${USER_HOME}"
34+
mkdir -p "${USER_HOME}/.claude"
35+
mkdir -p "${USER_HOME}/.codex"
36+
mkdir -p "${USER_HOME}/.config/gcloud"
37+
mkdir -p "${USER_HOME}/.config/gh"
38+
export HOME="${USER_HOME}"
39+
export XDG_CONFIG_HOME="${USER_HOME}/.config"
40+
export AGENT_RELAY_USER_ID="${WORKSPACE_OWNER_USER_ID}"
41+
log "HOME set to ${HOME} (user: ${WORKSPACE_OWNER_USER_ID})"
42+
else
43+
log "No WORKSPACE_OWNER_USER_ID set, using default HOME: ${HOME}"
44+
fi
45+
1946
WORKSPACE_DIR="${WORKSPACE_DIR:-/workspace}"
2047
REPO_LIST="${REPOSITORIES:-}"
2148

2249
mkdir -p "${WORKSPACE_DIR}"
2350
cd "${WORKSPACE_DIR}"
2451

52+
# ============================================================================
53+
# Persist workspace environment for SSH sessions
54+
# SSH sessions don't inherit the container's runtime environment, so we write
55+
# critical variables to /etc/profile.d/ which gets sourced by login shells
56+
# ============================================================================
57+
if [[ -n "${CLOUD_API_URL:-}" || -n "${WORKSPACE_ID:-}" ]]; then
58+
log "Persisting workspace environment for SSH sessions"
59+
cat > /etc/profile.d/workspace-env.sh <<ENVEOF
60+
# Workspace environment variables (auto-generated by entrypoint.sh)
61+
export WORKSPACE_ID="${WORKSPACE_ID:-}"
62+
export CLOUD_API_URL="${CLOUD_API_URL:-}"
63+
export WORKSPACE_TOKEN="${WORKSPACE_TOKEN:-}"
64+
export WORKSPACE_DIR="${WORKSPACE_DIR}"
65+
export HOME="${HOME}"
66+
export AGENT_RELAY_USER_ID="${AGENT_RELAY_USER_ID:-}"
67+
export AGENT_RELAY_DATA_DIR="${AGENT_RELAY_DATA_DIR:-/data}"
68+
ENVEOF
69+
chmod 644 /etc/profile.d/workspace-env.sh
70+
fi
71+
2572
# Configure Git credentials via the gateway (tokens auto-refresh via Nango)
2673
# The credential helper fetches fresh tokens from the cloud API on each git operation
2774
if [[ -n "${CLOUD_API_URL:-}" && -n "${WORKSPACE_ID:-}" && -n "${WORKSPACE_TOKEN:-}" ]]; then

docs/auth-revocation-handling.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Auth Revocation Handling Design
2+
3+
## Problem
4+
5+
Claude (and other AI CLIs) have limited active OAuth sessions. When a user authenticates:
6+
1. **Via relay** → Can revoke their local Claude instance's auth
7+
2. **Locally** → Can revoke the relay workspace agent's auth
8+
9+
Both scenarios need graceful handling.
10+
11+
## Detection Patterns
12+
13+
### Claude CLI Auth Revocation Indicators
14+
```
15+
- "Your session has expired"
16+
- "Please log in again"
17+
- "Authentication required"
18+
- "Unauthorized"
19+
- "session expired"
20+
- "invalid credentials"
21+
- API responses with 401/403
22+
- CLI exit with auth-related error message
23+
```
24+
25+
## Implementation Plan
26+
27+
### 1. Add Auth Error Detection to Parser/Wrapper
28+
29+
**File: `src/wrapper/auth-detection.ts` (new)**
30+
```typescript
31+
export const AUTH_REVOCATION_PATTERNS = [
32+
/session\s+(has\s+)?expired/i,
33+
/please\s+log\s*in\s+again/i,
34+
/authentication\s+required/i,
35+
/unauthorized/i,
36+
/invalid\s+credentials/i,
37+
/not\s+authenticated/i,
38+
/login\s+required/i,
39+
];
40+
41+
export function detectAuthRevocation(output: string): boolean {
42+
return AUTH_REVOCATION_PATTERNS.some(pattern => pattern.test(output));
43+
}
44+
```
45+
46+
**File: `src/wrapper/tmux-wrapper.ts` (modify)**
47+
- Import auth detection
48+
- In output processing, check for auth revocation patterns
49+
- When detected, emit 'auth_revoked' event and update status
50+
51+
### 2. Add Agent Status: `auth_revoked`
52+
53+
**File: `src/cloud/db/schema.ts`**
54+
- Document that `status` can be: `active`, `idle`, `ended`, `auth_revoked`
55+
56+
**File: `src/cloud/api/workspaces.ts`**
57+
- Add endpoint to update agent auth status
58+
- Add endpoint to trigger re-authentication
59+
60+
### 3. Relay Protocol Extension
61+
62+
**File: `src/protocol/types.ts`**
63+
Add new envelope type for auth status:
64+
```typescript
65+
export interface AuthStatusPayload {
66+
agentName: string;
67+
status: 'revoked' | 'valid';
68+
provider: string; // 'claude', 'codex', etc.
69+
message?: string;
70+
}
71+
```
72+
73+
### 4. Dashboard UI
74+
75+
**File: `src/dashboard/react-components/AgentCard.tsx`**
76+
- Show "Auth Required" badge when agent status is `auth_revoked`
77+
- Show "Re-authenticate" button
78+
79+
**File: `src/dashboard/react-components/AuthRevocationNotification.tsx` (new)**
80+
- Toast/banner notification when auth is revoked
81+
- Explains what happened and how to fix
82+
83+
**File: `src/dashboard/react-components/AuthWarningModal.tsx` (new)**
84+
- Warning before authenticating: "This may revoke other active sessions"
85+
- Checkbox: "Don't show again"
86+
- Continue / Cancel buttons
87+
88+
### 5. Re-authentication Flow
89+
90+
When user clicks "Re-authenticate":
91+
1. Opens the existing CLI auth flow (`/api/cli/:provider/start`)
92+
2. On success, agent status updated back to `active`
93+
3. Agent resumes operation (may need to restart or reconnect)
94+
95+
## Files to Create/Modify
96+
97+
### New Files
98+
- `src/wrapper/auth-detection.ts` - Auth error patterns and detection
99+
- `src/dashboard/react-components/AuthRevocationNotification.tsx`
100+
- `src/dashboard/react-components/AuthWarningModal.tsx`
101+
102+
### Modified Files
103+
- `src/wrapper/tmux-wrapper.ts` - Add auth detection in output processing
104+
- `src/wrapper/base-wrapper.ts` - Add auth status events
105+
- `src/protocol/types.ts` - Add AUTH_STATUS envelope type
106+
- `src/daemon/router.ts` - Handle auth status messages
107+
- `src/cloud/api/workspaces.ts` - Add auth status endpoints
108+
- `src/dashboard/react-components/AgentCard.tsx` - Show auth status
109+
- `src/dashboard/react-components/App.tsx` - Handle auth notifications
110+
- `src/cloud/api/onboarding.ts` - Add pre-auth warning flag
111+
112+
## API Endpoints
113+
114+
### POST /api/workspaces/:id/agents/:agentName/reauth
115+
Triggers re-authentication for an agent with revoked auth.
116+
117+
### GET /api/workspaces/:id/agents/:agentName/auth-status
118+
Returns current auth status for an agent.
119+
120+
### POST /api/cli/:provider/start (existing, modify)
121+
Add `skipWarning` parameter to bypass the session limit warning.
122+
123+
## Event Flow
124+
125+
```
126+
1. Agent running in workspace
127+
2. User authenticates Claude locally
128+
3. Cloud auth is revoked
129+
4. Agent CLI outputs "session expired" or similar
130+
5. Wrapper detects pattern → emits AUTH_REVOKED event
131+
6. Daemon receives event → updates agent status
132+
7. Cloud DB updated → status = 'auth_revoked'
133+
8. Dashboard polls/receives status → shows notification
134+
9. User clicks "Re-authenticate"
135+
10. CLI auth flow starts
136+
11. On success → agent status = 'active'
137+
12. Agent resumes (or user restarts agent)
138+
```
139+
140+
## Pre-Auth Warning
141+
142+
Before starting any CLI auth:
143+
1. Check if this is a provider with session limits (Claude, etc.)
144+
2. Show modal: "Authenticating Claude here may sign out other sessions"
145+
3. User confirms → proceed with auth
146+
4. User can check "Don't warn me again" (stored in localStorage)

src/cloud/api/billing.ts

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,66 @@
77
import { Router, Request } from 'express';
88
import { getBillingService, getAllPlans, getPlan, comparePlans } from '../billing/index.js';
99
import type { SubscriptionTier } from '../billing/types.js';
10-
import { getConfig } from '../config.js';
11-
import { db } from '../db/index.js';
10+
import { getConfig, isAdminUser } from '../config.js';
11+
import { db, type PlanType } from '../db/index.js';
1212
import { requireAuth } from './auth.js';
13+
import { getProvisioner, RESOURCE_TIERS } from '../provisioner/index.js';
14+
import { getResourceTierForPlan } from '../services/planLimits.js';
1315
import type Stripe from 'stripe';
1416

1517
export const billingRouter = Router();
1618

19+
/**
20+
* Resize user's workspaces to match their new plan tier
21+
* Called after plan upgrade/downgrade to adjust compute resources
22+
*
23+
* Strategy:
24+
* - Stopped workspaces: Resize immediately (no disruption)
25+
* - Running workspaces: Save config for next restart (no agent disruption)
26+
*
27+
* User can manually restart to get new resources immediately, or wait for
28+
* natural restart (auto-stop idle, manual restart, etc.)
29+
*/
30+
async function resizeWorkspacesForPlan(userId: string, newPlan: PlanType): Promise<void> {
31+
try {
32+
const workspaces = await db.workspaces.findByUserId(userId);
33+
if (workspaces.length === 0) return;
34+
35+
const provisioner = getProvisioner();
36+
const targetTierName = getResourceTierForPlan(newPlan);
37+
const targetTier = RESOURCE_TIERS[targetTierName];
38+
39+
console.log(`[billing] Upgrading ${workspaces.length} workspace(s) for user ${userId.substring(0, 8)} to ${targetTierName}`);
40+
41+
for (const workspace of workspaces) {
42+
if (workspace.status !== 'running' && workspace.status !== 'stopped') {
43+
console.log(`[billing] Skipping workspace ${workspace.id.substring(0, 8)} (status: ${workspace.status})`);
44+
continue;
45+
}
46+
47+
try {
48+
// For running workspaces: don't restart, apply on next restart
49+
// This prevents disrupting active agents
50+
const skipRestart = workspace.status === 'running';
51+
52+
await provisioner.resize(workspace.id, targetTier, skipRestart);
53+
54+
if (skipRestart) {
55+
console.log(`[billing] Queued resize for workspace ${workspace.id.substring(0, 8)} to ${targetTierName} (will apply on next restart)`);
56+
// TODO: Store pending upgrade in workspace metadata so we can show in UI
57+
} else {
58+
console.log(`[billing] Resized workspace ${workspace.id.substring(0, 8)} to ${targetTierName}`);
59+
}
60+
} catch (error) {
61+
console.error(`[billing] Failed to resize workspace ${workspace.id}:`, error);
62+
// Continue with other workspaces even if one fails
63+
}
64+
}
65+
} catch (error) {
66+
console.error('[billing] Failed to resize workspaces:', error);
67+
}
68+
}
69+
1770
/**
1871
* GET /api/billing/plans
1972
* Get all available billing plans
@@ -85,7 +138,6 @@ billingRouter.get('/compare', (req, res) => {
85138
*/
86139
billingRouter.get('/subscription', requireAuth, async (req, res) => {
87140
const userId = req.session.userId!;
88-
const billing = getBillingService();
89141

90142
try {
91143
// Fetch user from database
@@ -94,6 +146,28 @@ billingRouter.get('/subscription', requireAuth, async (req, res) => {
94146
return res.status(404).json({ error: 'User not found' });
95147
}
96148

149+
// Admin users have special status - show their current plan without Stripe
150+
if (isAdminUser(user.githubUsername)) {
151+
return res.json({
152+
tier: user.plan || 'enterprise',
153+
subscription: null,
154+
customer: null,
155+
isAdmin: true,
156+
});
157+
}
158+
159+
// If user doesn't have a Stripe customer ID and is on free tier, skip Stripe calls entirely
160+
// This prevents hanging on Stripe API calls for users who have never paid
161+
if (!user.stripeCustomerId && user.plan === 'free') {
162+
return res.json({
163+
tier: 'free',
164+
subscription: null,
165+
customer: null,
166+
});
167+
}
168+
169+
const billing = getBillingService();
170+
97171
// Get or create Stripe customer
98172
const customerId = user.stripeCustomerId ||
99173
await billing.getOrCreateCustomer(user.id, user.email || '', user.githubUsername);
@@ -150,7 +224,6 @@ billingRouter.post('/checkout', requireAuth, async (req, res) => {
150224
return;
151225
}
152226

153-
const billing = getBillingService();
154227
const config = getConfig();
155228

156229
try {
@@ -160,6 +233,26 @@ billingRouter.post('/checkout', requireAuth, async (req, res) => {
160233
return res.status(404).json({ error: 'User not found' });
161234
}
162235

236+
// Admin users get free upgrades - skip Stripe entirely
237+
if (isAdminUser(user.githubUsername)) {
238+
// Update user plan directly
239+
await db.users.update(userId, { plan: tier });
240+
console.log(`[billing] Admin user ${user.githubUsername} upgraded to ${tier} (free)`);
241+
242+
// Resize workspaces to match new plan (async)
243+
resizeWorkspacesForPlan(userId, tier as PlanType).catch((err) => {
244+
console.error(`[billing] Failed to resize workspaces for admin ${user.githubUsername}:`, err);
245+
});
246+
247+
// Return a fake session that redirects to success
248+
return res.json({
249+
sessionId: 'admin-upgrade',
250+
checkoutUrl: `${config.publicUrl}/billing/success?admin=true`,
251+
});
252+
}
253+
254+
const billing = getBillingService();
255+
163256
// Get or create customer
164257
const customerId = user.stripeCustomerId ||
165258
await billing.getOrCreateCustomer(user.id, user.email || '', user.githubUsername);
@@ -370,9 +463,9 @@ billingRouter.get('/invoices', requireAuth, async (req, res) => {
370463
return res.status(404).json({ error: 'User not found' });
371464
}
372465

466+
// No Stripe customer = no invoices, skip Stripe call entirely
373467
if (!user.stripeCustomerId) {
374-
res.json({ invoices: [] });
375-
return;
468+
return res.json({ invoices: [] });
376469
}
377470

378471
const billing = getBillingService();
@@ -466,11 +559,16 @@ billingRouter.post(
466559
// Extract subscription tier and update user's plan
467560
if (billingEvent.userId) {
468561
const subscription = billingEvent.data as unknown as Stripe.Subscription;
469-
const tier = billing.getTierFromSubscription(subscription);
562+
const tier = billing.getTierFromSubscription(subscription) as PlanType;
470563

471564
// Update user's plan in database
472565
await db.users.update(billingEvent.userId, { plan: tier });
473566
console.log(`Updated user ${billingEvent.userId} plan to: ${tier}`);
567+
568+
// Resize workspaces to match new plan (async, don't block webhook)
569+
resizeWorkspacesForPlan(billingEvent.userId, tier).catch((err) => {
570+
console.error(`Failed to resize workspaces for user ${billingEvent.userId}:`, err);
571+
});
474572
} else {
475573
console.warn('Subscription event received without userId:', billingEvent.id);
476574
}
@@ -482,6 +580,11 @@ billingRouter.post(
482580
if (billingEvent.userId) {
483581
await db.users.update(billingEvent.userId, { plan: 'free' });
484582
console.log(`User ${billingEvent.userId} subscription canceled, reset to free plan`);
583+
584+
// Resize workspaces down to free tier (async)
585+
resizeWorkspacesForPlan(billingEvent.userId, 'free').catch((err) => {
586+
console.error(`Failed to resize workspaces for user ${billingEvent.userId}:`, err);
587+
});
485588
}
486589
break;
487590
}

0 commit comments

Comments
 (0)