diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index a9288e73b..bce08eed5 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -51,3 +51,4 @@ {"id":"bd-git-worktrees","title":"Set up git worktrees for parallel multi-branch agent work","description":"Implement git worktrees to improve coordination when multiple agents work on different branches simultaneously.\n\nProblem:\n- Current workflow: agents work on different branches but single worktree causes switching overhead\n- Branch switching adds 30+ seconds per context switch\n- No true parallel work on multiple branches\n- Coordination complexity when agents need different branches\n\nSolution: Git worktrees allow multiple branches checked out simultaneously\n- Each branch in separate directory (/workspace/lead, /workspace/auth, /workspace/test-fix)\n- No switching overhead\n- Agents can work in parallel without blocking each other\n- Cleaner coordination model\n\nImplementation:\n1. Create worktree structure in .git/worktrees/\n2. Document worktree setup in lead.md\n3. Update agent spawn process to use worktrees when multi-branch work needed\n4. Example: git worktree add ../workspace-lead fix/workspace-persistence\n\nBenefit:\nParallel multi-branch coordination: 3+ agents on different branches simultaneously without context switching overhead.","priority":60,"status":"open","created_at":"2026-01-09T21:30:00Z","tags":["infrastructure","git-workflow","coordination","optimization"],"depends_on":[]} {"id":"bd-trail-config","title":"Trail CLI should read relay config file independently","description":"Trail CLI currently only reads TRAJECTORIES_DATA_DIR env var set by daemon. Should independently read ~/.config/agent-relay/relay.json when env var not set. This makes Trail CLI work standalone without daemon pre-configuration.\n\nRequirements:\n1. Trail CLI should check for config file before relying on env var\n2. Read ~/.config/agent-relay/relay.json or AGENT_RELAY_CONFIG_DIR/relay.json\n3. Parse storeInRepo setting to determine storage location\n4. Fall back to default ~/.config/agent-relay/trajectories/ if no config exists\n5. Environment variables still take precedence (can override config)\n\nBenefits:\n- Trail CLI independent of daemon environment setup\n- Consistent behavior across contexts (manual cli, daemon spawn, etc)\n- Config-driven approach instead of env var leakage\n\nFiles to modify:\n- Trail CLI source (not in this repo - upstream project)\n- Update integration.ts documentation about expected Trail CLI behavior\n- Add note to trajectory/config.ts about Trail CLI expectations","priority":60,"status":"open","created_at":"2026-01-09T22:37:00Z","tags":["trajectory","infrastructure","enhancement"],"depends_on":[]} {"id":"bd-mobile-log-scroll","title":"Fix mobile log viewer scrollability","description":"Enable scrolling for log viewers on mobile devices.\n\nProblem:\n- Log viewer on mobile is not scrollable due to overflow-hidden CSS\n- Desktop xterm.js works fine but mobile touch users cannot scroll\n\nSolution:\n- Add mobile-specific scroll handling to XTermLogViewer.tsx\n- Use Tailwind responsive classes (sm:, md:, lg:) for breakpoint handling\n- Keep desktop xterm.js scrolling experience unchanged\n- Ensure touch scrolling works smoothly on iOS and Android\n\nImplementation:\n- Modify terminal container overflow styles for mobile responsiveness\n- Test on mobile devices (iOS Safari, Android Chrome)\n- Ensure no console errors\n- Both inline and panel modes should work\n\nFiles:\n- src/dashboard/react-components/XTermLogViewer.tsx\n- src/dashboard/react-components/LogViewer.tsx (inline mode)\n- src/dashboard/react-components/LogViewerPanel.tsx (if needed)\n\nAcceptance Criteria:\n- Mobile users can scroll through logs with touch gestures\n- Desktop scrolling behavior unchanged\n- No console errors\n- Works on both iPhone and Android devices","priority":60,"status":"open","created_at":"2026-01-11T14:58:00Z","tags":["frontend","mobile","ui","bugfix"]} +{"id":"bd-workspace-custom-utilities","title":"Implement per-workspace custom utility installation","description":"Implement the design for per-workspace custom utility installation as specified in docs/design/per-workspace-custom-utilities.md.\n\n## Overview\nEnable workspace owners to install custom utilities (NPM packages, system packages, env vars, setup scripts) that are available to all agents in their workspace.\n\n## Key Features\n- Zero-migration approach using existing JSONB config field\n- Three-tier installation: provisioning-time, post-provisioning, runtime\n- Support for NPM packages, system packages, env vars, and setup scripts\n- Strong security controls (allowlists, validation, size limits)\n- Utility templates (Python dev, Node dev, Fly CLI)\n- Persistent storage in workspace /data volume\n\n## Implementation Tasks\n\n### 1. Schema Extension (src/cloud/db/schema.ts)\n- Add CustomUtilitiesConfig interface to WorkspaceConfig\n- Define NpmPackageSpec, SystemPackageSpec, SetupScriptSpec types\n- No migration needed (uses JSONB)\n\n### 2. Cloud API Endpoints (src/cloud/server.ts)\n- GET /api/workspaces/:id/utilities - List installed utilities\n- POST /api/workspaces/:id/utilities - Add utilities to config\n- DELETE /api/workspaces/:id/utilities/:utilityId - Remove utility\n\n### 3. Workspace Daemon API (src/workspace-provisioner/index.ts)\n- GET /api/utilities/status - Check installation status\n- POST /api/utilities/install - Trigger installation (async job)\n- GET /api/utilities/jobs/:jobId - Check job status\n\n### 4. Installation Logic (deploy/workspace/entrypoint.sh)\n- Add provisioning-time installation section\n- Read CUSTOM_UTILITIES env var\n- Install NPM packages, system packages, setup scripts\n- Handle errors and log to /data/utilities/install.log\n\n### 5. Runtime Integration\n- Inject custom env vars to agent environment\n- Ensure utilities are in PATH\n- Document usage for agents\n\n### 6. Security Controls\n- System package allowlist (build-essential, curl, wget, git, jq, etc.)\n- NPM package name validation (no shell injection)\n- Setup script size limit (100KB)\n- Protected env var names (PATH, HOME, USER, etc.)\n- Scripts run as non-root by default\n\n### 7. Testing\n- Unit tests for validation logic\n- Integration test: install fly CLI\n- Integration test: install custom npm package\n- Integration test: setup script execution\n\n## Example Use Case\nWorkspace owner wants fly CLI for agent-relay workspace:\n```json\n{\n \"customUtilities\": {\n \"version\": 1,\n \"systemPackages\": [\n {\"name\": \"fly\", \"source\": \"https://fly.io/install.sh\", \"type\": \"script\"}\n ]\n }\n}\n```\n\n## Related\n- Design: docs/design/per-workspace-custom-utilities.md\n- PR: #192 (contains design spec)\n- Designed by: WorkspaceCustomToolsArchitect","priority":70,"status":"open","created_at":"2026-01-15T07:00:00Z","tags":["workspace","infrastructure","feature","utilities"],"depends_on":[]} diff --git a/docs/design/per-workspace-custom-utilities.md b/docs/design/per-workspace-custom-utilities.md new file mode 100644 index 000000000..136dd47e2 --- /dev/null +++ b/docs/design/per-workspace-custom-utilities.md @@ -0,0 +1,673 @@ +# Per-Workspace Custom Utility Installation + +## Overview + +This design enables workspace owners to install custom utilities, packages, and tools that are available to all agents running in their workspace. The solution leverages the existing JSONB `config` field for schema-less configuration without requiring database migrations. + +## Goals + +1. **Zero-migration design** - Use existing JSONB config field +2. **Support multiple installation types** - NPM packages, system packages, custom scripts +3. **Persistence across restarts** - Utilities survive workspace restarts +4. **Post-provisioning installation** - Install utilities without reprovisioning +5. **Environment variable injection** - Custom env vars available to all agents +6. **Security-conscious** - Validate inputs, isolate execution + +## Architecture + +### 1. Schema Extension + +Extend `WorkspaceConfig` interface in `src/cloud/db/schema.ts`: + +```typescript +export interface WorkspaceConfig { + // ... existing fields ... + providers?: string[]; + repositories?: string[]; + supervisorEnabled?: boolean; + maxAgents?: number; + resourceTier?: 'small' | 'medium' | 'large' | 'xlarge'; + agentPolicy?: WorkspaceAgentPolicy; + + // NEW: Custom utilities configuration + customUtilities?: CustomUtilitiesConfig; +} + +export interface CustomUtilitiesConfig { + /** Schema version for backward compatibility */ + version: 1; + + /** NPM packages to install globally */ + npmPackages?: NpmPackageSpec[]; + + /** System packages (apt) to install */ + systemPackages?: SystemPackageSpec[]; + + /** Custom environment variables */ + environmentVariables?: Record; + + /** Custom setup scripts to run */ + setupScripts?: SetupScriptSpec[]; + + /** Installation status tracking */ + installationStatus?: InstallationStatus; +} + +export interface NpmPackageSpec { + /** Package name (e.g., "typescript", "@anthropic/sdk") */ + name: string; + /** Version constraint (e.g., "^5.0.0", "latest") */ + version?: string; + /** Install globally (default: true for CLI tools, false for libs) */ + global?: boolean; +} + +export interface SystemPackageSpec { + /** Package name as known to apt (e.g., "jq", "redis-tools") */ + name: string; + /** Alternative package name if different from 'name' */ + aptPackage?: string; +} + +export interface SetupScriptSpec { + /** Script identifier for logging/tracking */ + name: string; + /** Script content (base64 encoded for safety) */ + content: string; + /** Run as root (default: false, runs as workspace user) */ + runAsRoot?: boolean; + /** Run order (lower runs first, default: 100) */ + order?: number; + /** Execution timeout in milliseconds (default: 300000 = 5 minutes) */ + timeoutMs?: number; +} + +export interface InstallationStatus { + /** Overall status */ + status: 'pending' | 'installing' | 'installed' | 'failed'; + /** Last installation attempt timestamp */ + lastAttempt?: string; + /** Per-item installation status */ + items?: Record; +} +``` + +### 2. Installation Tiers + +#### Tier 1: Provisioning-time Installation + +During workspace provisioning, utilities are installed before the daemon starts. + +**Flow:** +1. Cloud server provisions workspace with `customUtilities` in config +2. `CUSTOM_UTILITIES` env var is passed to container (JSON-encoded config) +3. `entrypoint.sh` runs utility installation before daemon startup +4. Status is written to `/data/utilities/status.json` + +**entrypoint.sh additions:** +```bash +# ============================================================================ +# Custom Utilities Installation +# ============================================================================ + +install_custom_utilities() { + if [[ -z "${CUSTOM_UTILITIES:-}" ]]; then + return 0 + fi + + log "Installing custom utilities..." + local status_file="/data/utilities/status.json" + mkdir -p /data/utilities + + # Initialize status + echo '{"status":"installing","lastAttempt":"'$(date -Iseconds)'","items":{}}' > "$status_file" + + # Parse and install NPM packages + local npm_packages=$(echo "$CUSTOM_UTILITIES" | jq -r '.npmPackages[]? | "\(.name)@\(.version // "latest")"') + for pkg in $npm_packages; do + log "Installing npm package: $pkg" + if npm install -g "$pkg" 2>&1; then + update_status "$status_file" "npm:$pkg" "installed" + else + update_status "$status_file" "npm:$pkg" "failed" "npm install failed" + fi + done + + # Parse and install system packages + local sys_packages=$(echo "$CUSTOM_UTILITIES" | jq -r '.systemPackages[]? | .aptPackage // .name') + if [[ -n "$sys_packages" ]]; then + apt-get update -qq + for pkg in $sys_packages; do + log "Installing system package: $pkg" + if apt-get install -y -qq "$pkg" 2>&1; then + update_status "$status_file" "apt:$pkg" "installed" + else + update_status "$status_file" "apt:$pkg" "failed" "apt install failed" + fi + done + fi + + # Set environment variables + local env_vars=$(echo "$CUSTOM_UTILITIES" | jq -r '.environmentVariables // {} | to_entries[] | "export \(.key)=\"\(.value)\""') + if [[ -n "$env_vars" ]]; then + echo "# Custom workspace environment variables" >> /etc/profile.d/workspace-env.sh + echo "$env_vars" >> /etc/profile.d/workspace-env.sh + fi + + # Run setup scripts (sorted by order) + local scripts=$(echo "$CUSTOM_UTILITIES" | jq -r '.setupScripts | sort_by(.order // 100)[]? | @base64') + for script_b64 in $scripts; do + local script=$(echo "$script_b64" | base64 -d) + local script_name=$(echo "$script" | jq -r '.name') + local script_content=$(echo "$script" | jq -r '.content' | base64 -d) + local run_as_root=$(echo "$script" | jq -r '.runAsRoot // false') + + log "Running setup script: $script_name" + local script_file="/tmp/setup-${script_name}.sh" + echo "$script_content" > "$script_file" + chmod +x "$script_file" + + if [[ "$run_as_root" == "true" ]]; then + if bash "$script_file" 2>&1; then + update_status "$status_file" "script:$script_name" "installed" + else + update_status "$status_file" "script:$script_name" "failed" "script execution failed" + fi + else + if sudo -u workspace bash "$script_file" 2>&1; then + update_status "$status_file" "script:$script_name" "installed" + else + update_status "$status_file" "script:$script_name" "failed" "script execution failed" + fi + fi + rm -f "$script_file" + done + + # Update final status + if jq -e '.items | to_entries | map(select(.value.status == "failed")) | length == 0' "$status_file" > /dev/null; then + jq '.status = "installed"' "$status_file" > "$status_file.tmp" && mv "$status_file.tmp" "$status_file" + log "Custom utilities installed successfully" + else + jq '.status = "failed"' "$status_file" > "$status_file.tmp" && mv "$status_file.tmp" "$status_file" + log "WARNING: Some utilities failed to install" + fi +} + +update_status() { + local file="$1" key="$2" status="$3" error="${4:-}" + local ts=$(date -Iseconds) + if [[ -n "$error" ]]; then + jq --arg k "$key" --arg s "$status" --arg e "$error" --arg t "$ts" \ + '.items[$k] = {status: $s, error: $e, installedAt: $t}' "$file" > "$file.tmp" && mv "$file.tmp" "$file" + else + jq --arg k "$key" --arg s "$status" --arg t "$ts" \ + '.items[$k] = {status: $s, installedAt: $t}' "$file" > "$file.tmp" && mv "$file.tmp" "$file" + fi +} +``` + +#### Tier 2: Post-provisioning Installation + +Install utilities in a running workspace without reprovisioning. + +**New API Endpoints:** + +```typescript +// src/cloud/api/workspaces.ts + +// GET /api/workspaces/:id/utilities +// Returns current utility configuration and installation status +router.get('/:id/utilities', requireWorkspaceAccess, async (req, res) => { + const workspace = await getWorkspace(req.params.id); + const utilities = workspace.config.customUtilities || { version: 1 }; + + // Fetch live status from workspace if running + if (workspace.status === 'running' && workspace.publicUrl) { + try { + const statusRes = await fetch(`${workspace.publicUrl}/api/utilities/status`, { + headers: { Authorization: `Bearer ${workspace.token}` } + }); + const liveStatus = await statusRes.json(); + utilities.installationStatus = liveStatus; + } catch (e) { + // Use stored status if workspace unreachable + } + } + + res.json(utilities); +}); + +// POST /api/workspaces/:id/utilities +// Install or update utilities +router.post('/:id/utilities', requireWorkspaceAccess, async (req, res) => { + const { npmPackages, systemPackages, environmentVariables, setupScripts } = req.body; + const workspace = await getWorkspace(req.params.id); + + // Validate input + const validation = validateUtilitiesConfig(req.body); + if (!validation.valid) { + return res.status(400).json({ error: validation.errors }); + } + + // Merge with existing config + const newConfig = { + ...workspace.config, + customUtilities: { + version: 1, + npmPackages: mergePackages(workspace.config.customUtilities?.npmPackages, npmPackages), + systemPackages: mergePackages(workspace.config.customUtilities?.systemPackages, systemPackages), + environmentVariables: { ...workspace.config.customUtilities?.environmentVariables, ...environmentVariables }, + setupScripts: mergeScripts(workspace.config.customUtilities?.setupScripts, setupScripts), + installationStatus: { status: 'pending' } + } + }; + + // Update database + await updateWorkspaceConfig(workspace.id, newConfig); + + // Trigger installation if workspace is running + if (workspace.status === 'running' && workspace.publicUrl) { + const installRes = await fetch(`${workspace.publicUrl}/api/utilities/install`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${workspace.token}` + }, + body: JSON.stringify(newConfig.customUtilities) + }); + + if (!installRes.ok) { + return res.status(500).json({ error: 'Failed to trigger installation' }); + } + + const job = await installRes.json(); + return res.json({ + message: 'Installation started', + jobId: job.id, + config: newConfig.customUtilities + }); + } + + res.json({ + message: 'Configuration saved (will install on next workspace start)', + config: newConfig.customUtilities + }); +}); + +// DELETE /api/workspaces/:id/utilities/:type/:name +// Remove a specific utility +router.delete('/:id/utilities/:type/:name', requireWorkspaceAccess, async (req, res) => { + const { type, name } = req.params; + const workspace = await getWorkspace(req.params.id); + + // Remove from config + const utilities = workspace.config.customUtilities || { version: 1 }; + + switch (type) { + case 'npm': + utilities.npmPackages = utilities.npmPackages?.filter(p => p.name !== name); + break; + case 'apt': + utilities.systemPackages = utilities.systemPackages?.filter(p => p.name !== name); + break; + case 'env': + delete utilities.environmentVariables?.[name]; + break; + case 'script': + utilities.setupScripts = utilities.setupScripts?.filter(s => s.name !== name); + break; + } + + await updateWorkspaceConfig(workspace.id, { ...workspace.config, customUtilities: utilities }); + + // Trigger uninstall if workspace is running + if (workspace.status === 'running' && workspace.publicUrl) { + await fetch(`${workspace.publicUrl}/api/utilities/uninstall`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${workspace.token}` + }, + body: JSON.stringify({ type, name }) + }); + } + + res.json({ message: 'Utility removed' }); +}); +``` + +**Workspace Daemon Endpoints:** + +```typescript +// src/daemon/api/utilities.ts (new file) + +// GET /api/utilities/status +// Returns installation status from /data/utilities/status.json +router.get('/status', async (req, res) => { + try { + const status = await fs.readFile('/data/utilities/status.json', 'utf-8'); + res.json(JSON.parse(status)); + } catch (e) { + res.json({ status: 'not_configured', items: {} }); + } +}); + +// POST /api/utilities/install +// Installs utilities in the running workspace +router.post('/install', requireWorkspaceToken, async (req, res) => { + const config = req.body as CustomUtilitiesConfig; + + // Create job for tracking + const jobId = crypto.randomUUID(); + const job = { + id: jobId, + status: 'running', + startedAt: new Date().toISOString(), + config + }; + + // Store job state + await fs.writeFile(`/data/utilities/jobs/${jobId}.json`, JSON.stringify(job)); + + // Run installation in background + installUtilitiesAsync(jobId, config).catch(err => { + logger.error('Utility installation failed', { jobId, error: err.message }); + }); + + res.json({ id: jobId, status: 'running' }); +}); + +// GET /api/utilities/jobs/:id +// Get job status +router.get('/jobs/:id', async (req, res) => { + try { + const job = await fs.readFile(`/data/utilities/jobs/${req.params.id}.json`, 'utf-8'); + res.json(JSON.parse(job)); + } catch (e) { + res.status(404).json({ error: 'Job not found' }); + } +}); + +async function installUtilitiesAsync(jobId: string, config: CustomUtilitiesConfig): Promise { + const statusFile = '/data/utilities/status.json'; + const jobFile = `/data/utilities/jobs/${jobId}.json`; + + // Initialize status + await fs.writeFile(statusFile, JSON.stringify({ + status: 'installing', + lastAttempt: new Date().toISOString(), + items: {} + })); + + // Install NPM packages + for (const pkg of config.npmPackages || []) { + const pkgSpec = `${pkg.name}@${pkg.version || 'latest'}`; + const key = `npm:${pkg.name}`; + + try { + await execAsync(`npm install -g ${pkgSpec}`); + await updateItemStatus(statusFile, key, 'installed'); + } catch (err) { + await updateItemStatus(statusFile, key, 'failed', err.message); + } + } + + // Install system packages + if (config.systemPackages?.length) { + await execAsync('apt-get update -qq'); + for (const pkg of config.systemPackages) { + const aptPkg = pkg.aptPackage || pkg.name; + const key = `apt:${pkg.name}`; + + try { + await execAsync(`apt-get install -y -qq ${aptPkg}`); + await updateItemStatus(statusFile, key, 'installed'); + } catch (err) { + await updateItemStatus(statusFile, key, 'failed', err.message); + } + } + } + + // Set environment variables + if (config.environmentVariables && Object.keys(config.environmentVariables).length) { + const envLines = Object.entries(config.environmentVariables) + .map(([k, v]) => `export ${k}="${v}"`) + .join('\n'); + await fs.appendFile('/etc/profile.d/workspace-env.sh', `\n# Custom utilities\n${envLines}\n`); + } + + // Run setup scripts + const scripts = [...(config.setupScripts || [])].sort((a, b) => (a.order || 100) - (b.order || 100)); + for (const script of scripts) { + const key = `script:${script.name}`; + const content = Buffer.from(script.content, 'base64').toString('utf-8'); + const scriptFile = `/tmp/setup-${script.name}-${jobId}.sh`; + + try { + await fs.writeFile(scriptFile, content, { mode: 0o755 }); + const cmd = script.runAsRoot ? `bash ${scriptFile}` : `sudo -u workspace bash ${scriptFile}`; + await execAsync(cmd); + await updateItemStatus(statusFile, key, 'installed'); + } catch (err) { + await updateItemStatus(statusFile, key, 'failed', err.message); + } finally { + await fs.unlink(scriptFile).catch(() => {}); + } + } + + // Finalize status + const finalStatus = JSON.parse(await fs.readFile(statusFile, 'utf-8')); + const failedItems = Object.values(finalStatus.items).filter((i: any) => i.status === 'failed'); + finalStatus.status = failedItems.length === 0 ? 'installed' : 'failed'; + await fs.writeFile(statusFile, JSON.stringify(finalStatus, null, 2)); + + // Update job + const job = JSON.parse(await fs.readFile(jobFile, 'utf-8')); + job.status = finalStatus.status === 'installed' ? 'completed' : 'failed'; + job.completedAt = new Date().toISOString(); + await fs.writeFile(jobFile, JSON.stringify(job, null, 2)); +} +``` + +### 3. Storage Layout + +``` +/data/ +├── utilities/ +│ ├── status.json # Installation status tracking +│ ├── jobs/ # Post-provisioning job history +│ │ └── {jobId}.json +│ └── scripts/ # Archived setup scripts +├── bin/ # Custom binaries (added to PATH) +├── repos/ # Repository checkouts +└── users/ # Per-user home directories +``` + +### 4. CLI Integration + +**New commands for `agent-relay workspace utilities`:** + +```bash +# List installed utilities +agent-relay workspace utilities list --workspace + +# Install NPM package +agent-relay workspace utilities install npm typescript@5.0.0 --workspace + +# Install system package +agent-relay workspace utilities install apt jq --workspace + +# Set environment variable +agent-relay workspace utilities env set API_KEY=secret123 --workspace + +# Run setup script +agent-relay workspace utilities script run ./setup.sh --workspace + +# Check installation status +agent-relay workspace utilities status --workspace +``` + +### 5. Dashboard UI + +New "Utilities" tab in workspace settings: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Workspace Settings > Utilities │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ NPM Packages [+ Add] │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ typescript@5.3.3 ✓ Installed [Remove] │ │ +│ │ @anthropic/sdk@latest ✓ Installed [Remove] │ │ +│ │ prettier@3.0.0 ⏳ Installing... │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ System Packages [+ Add] │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ jq ✓ Installed [Remove] │ │ +│ │ redis-tools ✓ Installed [Remove] │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ Environment Variables [+ Add] │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ CUSTOM_FLAG = "enabled" [Edit] [Del] │ │ +│ │ API_TIMEOUT = "30000" [Edit] [Del] │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ Setup Scripts [+ Add] │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ configure-git.sh ✓ Ran successfully [View] [Del] │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ [Save & Install] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Security Considerations + +### Input Validation + +```typescript +function validateUtilitiesConfig(config: CustomUtilitiesConfig): ValidationResult { + const errors: string[] = []; + + // NPM packages: validate package names (no shell injection) + for (const pkg of config.npmPackages || []) { + if (!/^(@[\w-]+\/)?[\w.-]+$/.test(pkg.name)) { + errors.push(`Invalid NPM package name: ${pkg.name}`); + } + if (pkg.version && !/^[\w^~<>=.*-]+$/.test(pkg.version)) { + errors.push(`Invalid version constraint: ${pkg.version}`); + } + } + + // System packages: validate against allowlist (strict, additions via support request) + const allowedAptPackages = new Set([ + // Build tools + 'build-essential', 'git', 'make', 'cmake', + // CLI utilities + 'curl', 'wget', 'jq', 'htop', 'tree', 'vim', 'nano', 'unzip', 'zip', + // Database clients + 'redis-tools', 'postgresql-client', 'mysql-client', + // Languages + 'python3', 'python3-pip', 'python3-venv', 'ruby', 'golang-go', + // Fly.io deployment + 'wireguard-tools', + ]); + + for (const pkg of config.systemPackages || []) { + const aptPkg = pkg.aptPackage || pkg.name; + if (!allowedAptPackages.has(aptPkg)) { + errors.push(`System package not in allowlist: ${aptPkg}`); + } + } + + // Environment variables: no sensitive key names + const forbiddenEnvKeys = ['PATH', 'HOME', 'USER', 'SHELL', 'LD_PRELOAD', 'LD_LIBRARY_PATH']; + for (const key of Object.keys(config.environmentVariables || {})) { + if (forbiddenEnvKeys.includes(key)) { + errors.push(`Cannot override protected environment variable: ${key}`); + } + } + + // Setup scripts: size limit + for (const script of config.setupScripts || []) { + const decoded = Buffer.from(script.content, 'base64'); + if (decoded.length > 1024 * 100) { // 100KB limit + errors.push(`Script ${script.name} exceeds 100KB limit`); + } + } + + return { valid: errors.length === 0, errors }; +} +``` + +### Execution Isolation + +- Setup scripts run as `workspace` user by default (not root) +- Scripts run in isolated `/tmp` directory +- Network access controlled by container policy +- Resource limits enforced via cgroups + +### Rate Limiting + +- Maximum 10 installation requests per hour per workspace +- Maximum 5 concurrent installation jobs per user + +## Implementation Phases + +### Phase 1: Schema & Config (No Migration) +- [ ] Add `CustomUtilitiesConfig` interface to `schema.ts` +- [ ] Add validation functions +- [ ] Unit tests for config validation + +### Phase 2: Provisioning Integration +- [ ] Modify `entrypoint.sh` to handle `CUSTOM_UTILITIES` env var +- [ ] Pass utilities config during provisioning +- [ ] Status file persistence at `/data/utilities/status.json` + +### Phase 3: Post-Provisioning API +- [ ] Add workspace daemon endpoints (`/api/utilities/*`) +- [ ] Add cloud API proxy endpoints (`/api/workspaces/:id/utilities`) +- [ ] Job tracking and async installation + +### Phase 4: CLI & Dashboard +- [ ] `agent-relay workspace utilities` commands +- [ ] Dashboard Utilities settings tab +- [ ] Real-time installation status updates + +## Testing Strategy + +1. **Unit tests**: Config validation, merge functions +2. **Integration tests**: API endpoints, daemon communication +3. **E2E tests**: Full provisioning with utilities, post-provisioning install +4. **Manual verification**: Dashboard UI, CLI commands + +## Decisions (Resolved) + +1. **Package allowlist scope**: Start strict with common packages (build-essential, curl, wget, git, jq, htop, tree, vim, nano, redis-tools, postgresql-client, mysql-client, python3, python3-pip, ruby, golang-go). Owners can request additions through support. + +2. **Script execution timeout**: 5 minutes default, configurable per-script via `timeoutMs` field in `SetupScriptSpec`. + +3. **Utility templates**: Implement starter templates: + - **Python dev**: python3, python3-pip, virtualenv, black, mypy + - **Node dev**: node (latest LTS), npm globals (typescript, eslint, prettier) + - **Fly CLI**: flyctl, wireguard-tools + +4. **Billing implications**: Deferred - not counted toward usage metrics initially. + +## Next Steps + +- [x] Design document created +- [ ] DashboardUI review for implementation priority +- [ ] Create implementation beads/tasks if approved +- [ ] Phase 1-4 implementation + +--- + +*Design approved by Lead on 2026-01-15* diff --git a/src/cloud/server.ts b/src/cloud/server.ts index 83bcf8f16..5ead2f283 100644 --- a/src/cloud/server.ts +++ b/src/cloud/server.ts @@ -561,23 +561,47 @@ export async function createServer(): Promise { } // Create the channel - const channel = await db.channels.create({ - workspaceId, - channelId, - name: displayName, - description, - visibility: isPrivate ? 'private' : 'public', - status: 'active', - createdBy, - }); + console.log('[channels] Creating channel:', { workspaceId, channelId, displayName, createdBy }); + let channel; + try { + channel = await db.channels.create({ + workspaceId, + channelId, + name: displayName, + description, + visibility: isPrivate ? 'private' : 'public', + status: 'active', + createdBy, + }); + console.log('[channels] Channel created:', channel.id); + } catch (createError) { + const err = createError as Error; + console.error('[channels] Failed to create channel in database:', { + message: err.message, + stack: err.stack, + }); + throw createError; + } // Add creator as owner - await db.channelMembers.addMember({ - channelId: channel.id, - memberId: createdBy, - memberType: 'user', - role: 'owner', - }); + try { + await db.channelMembers.addMember({ + channelId: channel.id, + memberId: createdBy, + memberType: 'user', + role: 'owner', + }); + console.log('[channels] Added creator as owner:', createdBy); + } catch (memberError) { + const err = memberError as Error; + console.error('[channels] Failed to add channel member:', { + message: err.message, + stack: err.stack, + channelId: channel.id, + memberId: createdBy, + }); + throw memberError; + } // Handle invites if provided // Supports: comma-separated string, array of strings, or array of {id, type} objects @@ -659,8 +683,19 @@ export async function createServer(): Promise { warnings: memberWarnings.length > 0 ? memberWarnings : undefined, }); } catch (error) { - console.error('[channels] Error creating channel:', error); - res.status(500).json({ error: 'Failed to create channel' }); + const err = error as Error; + console.error('[channels] Error creating channel:', { + message: err.message, + stack: err.stack, + name: err.name, + workspaceId: req.body.workspaceId, + channelName: req.body.name, + }); + // Include error message for debugging (safe since this is authenticated) + res.status(500).json({ + error: 'Failed to create channel', + message: err.message, + }); } });