This document describes the multi-provider authentication system and per-user workspace isolation feature.
- Overview
- Authentication Modes
- Authentication Providers
- Per-User Workspace Isolation
- Configuration
- API Reference
- Architecture
Runloop supports two modes of operation:
| Mode | Description | Use Case |
|---|---|---|
| Single-User | No authentication required, uses default user ID | Local development, personal use |
| Multi-User | JWT authentication with multiple provider support | Team deployment, production |
In both modes, workspace files are organized per-user to ensure data isolation.
When MULTI_USER_MODE is not set or set to false:
- No login required
- All requests use a default user ID (
DEFAULT_USER_IDenv or"default-user") - Per-user folders are stored under
/_users/default-user/ - Suitable for local development and personal deployments
When MULTI_USER_MODE=true:
- JWT authentication required for all API requests
- Multiple authentication providers supported
- Each user gets isolated workspace folders
- Per-user folders stored under
/_users/{userID}/
The system supports multiple authentication providers that can be enabled simultaneously.
| Provider | Type | Description |
|---|---|---|
simple |
Credentials | Username/password from environment variable |
cognito |
OAuth | AWS Cognito User Pool with hosted UI |
supabase |
OAuth | Supabase Auth |
Username/password authentication against the user directory,
config/users.json in the shared workspace (argon2id hashes, never plain
text). See docs/design/user_accounts_and_workflow_sharing.md for the model.
Bootstrap — the directory is seeded from the environment on the first start and the env vars can then be removed:
AUTH_USERS=admin:password123,user1:secret456 # imported (hashed) into config/users.json once
ADMIN_USERS=admin # usernames or emails that are adminsAfter that, accounts are managed in the app: an admin opens Users & access
(the shield button in the workflow toolbar, multi-user mode) or calls the admin
API below. AUTH_USERS keeps working as a login fallback for any name not yet
in the directory, so nothing breaks mid-migration.
Account record (config/users.json):
{ "users": [ { "id": "…", "username": "carol", "email": "", "password_hash": "$argon2id$…",
"admin": false, "can_create": false, "products": ["video-studio"], "disabled": false } ] }admin: manages users and product access; can open any workflow.can_create:falseis the read-only user — cannot create anything, sees only what is shared.products: which product surfaces the account may open. Admins ignore it; a member with an empty list may open all; a read-only account with an empty list may open none.- SSO users (Cognito/Supabase) are created on first login with nothing enabled unless
ADMIN_USERSnames them; an admin switches them on. - A disabled account is refused immediately, even with a still-valid token.
Workflows stay in the shared Workflow/ folder. The access tier the runtime
enforces (read / write / owner, see PLAT-262 for what read may do) is
derived from the user directory first: an admin is owner, an account with
can_create is write, a read-only account is read. The env/file tiers
below apply only to identities the directory does not know, and an
unconfigured deployment keeps full owner-level access for everyone, as before.
Per-workflow ownership and sharing is phase 3 of the design doc and not built yet.
Configuration:
WORKFLOW_USER_PERMISSIONS=admin:owner,user1:read,user2:writeYou can also use list-based variables:
WORKFLOW_OWNER_USERS=admin
WORKFLOW_WRITE_USERS=user2
WORKFLOW_READ_USERS=user1Entries can match the auth username, user ID, or email address. The owner-only GET /api/auth/users endpoint returns the current AUTH_USERS list with each user's workflow access.
OAuth authentication via AWS Cognito hosted UI.
Configuration:
AUTH_PROVIDERS=cognito
COGNITO_USER_POOL_ID=us-east-1_xxxxx
COGNITO_CLIENT_ID=xxxxxxxxx
COGNITO_DOMAIN=myapp.auth.us-east-1.amazoncognito.com
AWS_REGION=us-east-1Features:
- Enterprise SSO support
- User pool management via AWS Console
OAuth authentication via Supabase Auth.
Configuration:
AUTH_PROVIDERS=supabase
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=eyJxxxFeatures:
- Multiple social login options
- Email/password authentication
- Row-level security integration
Enable multiple providers simultaneously:
AUTH_PROVIDERS=simple,cognito,supabase
AUTH_USERS=admin:password123
COGNITO_USER_POOL_ID=us-east-1_xxxxx
COGNITO_CLIENT_ID=xxxxxxxxx
COGNITO_DOMAIN=myapp.auth.us-east-1.amazoncognito.com
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=eyJxxxThe login page will display all configured providers.
The workspace uses a hybrid folder model:
/app/workspace-docs/
├── _users/ # Per-user isolated folders
│ ├── default/ # Fallback for single-user mode
│ │ ├── Chats/ # User's chat history
│ │ ├── Downloads/ # User's downloads
│ │ └── (plan folders live under Chats/)
│ └── user-abc123/ # Multi-user: each user gets own folder
│ ├── Chats/
│ └── Downloads/
├── Chats -> _users/default/Chats # Symlink (for shell command access)
├── Downloads -> _users/default/Downloads
├── skills/ # Shared across all users
└── Workflow/ # Shared across all users
| Folder | Type | Description |
|---|---|---|
Chats/ |
Per-User | Chat session outputs, skill files, user scripts, multi-agent plan folders |
Downloads/ |
Per-User | User downloads and imports |
skills/ |
Shared | Installed skills/templates |
Workflow/ |
Shared | Workflow definitions and runs |
-
User ID Resolution:
- Multi-user mode: User ID from JWT token claims
- Single-user mode: Default user ID from environment (
"default")
-
Path Routing (Document/File API):
- Requests to
Chats/*orDownloads/*→/_users/{userID}/... - Requests to
skills/*,Workflow/*→ root level (shared) - Implemented in
workspace/utils/path.goviaResolveUserPath()
- Requests to
-
Symlinks for Shell Commands:
- On startup,
EnsurePerUserSymlinks()creates root-level symlinks:Chats/ -> _users/{userID}/Chats/ - Shell commands can use logical paths (e.g.,
cat Chats/file.md) and the symlink resolves to the physical per-user location - Symlinks are per the default user in single-user mode; multi-user deployments use the Isolator's WritePathMappings instead
- On startup,
-
Automatic Migration:
- On startup, existing
Chats/andDownloads/at root level are migrated to/_users/default/ - One-time migration for backwards compatibility
- On startup, existing
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ AuthMiddleware│ ──► │ Agent Context │ ──► │ Workspace Client│
│ (extracts ID) │ │ (user_id key) │ │ (X-User-ID hdr) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Workspace API │
│ (resolves path) │
└─────────────────┘
Shell commands (execute_shell_command) run inside the workspace Docker container and are sandboxed using Linux mount namespaces via unshare -m. The FolderGuard system controls what the LLM can read and write.
| Mode | When Used | Mechanism |
|---|---|---|
| Deny-list (Mode 1) | Chat mode (default tools) | Hides _users/ with tmpfs overlay; everything else visible |
| Allow-list (Mode 2) | Multi-agent / workflow mode | Hides entire workspace with tmpfs, then selectively bind-mounts ReadPaths (read-only) and WritePaths (read-write) |
The default FolderGuard (getDefaultFolderGuard()) blocks only _users/ to prevent direct access to the internal per-user directory structure. The LLM accesses per-user folders via their logical symlinked paths (e.g., Chats/).
BlockedPaths: ["_users"] # Hidden with tmpfs
ReadPaths: [] # Not used (everything else is visible)
WritePaths: [] # Not used
The agent backend additionally restricts which folders the LLM can write to via wrapExecutorsWithChatModeFolderGuard() — writes are only allowed to Chats/ (and skills/custom/ if the skill creator is active). This is enforced at the agent level before the shell command reaches the workspace API.
Multi-agent chat sub-agents use wrapExecutorsWithChatModeFolderGuard() with the standard Chats/ allow list:
ReadPaths: ["Chats/", "Downloads/", "skills/", "subagents/", "Workflow/", "config/", "memories/"]
WritePaths: ["Chats/", "Downloads/", "config/", "memories/"]
The Isolator creates a mount namespace:
- Bind-mounts the original workspace to a temp location
- Covers
/app/workspace-docswith tmpfs (hides everything) - Bind-mounts ReadPaths back (read-only) from the temp copy
- Bind-mounts WritePaths back (read-write) from the temp copy
For per-user folders (Chats/), the shell handler creates WritePathMappings that map logical paths to physical per-user paths:
WritePaths: ["Chats/"]
WritePathMappings: {
"Chats/": "_users/default/Chats/"
}
The Isolator uses these mappings to source files from _users/{userID}/Chats/ while mounting them at the logical Chats/ path. This way, shell commands use logical paths transparently, and each user's data stays isolated.
The agent backend enforces additional restrictions before shell commands reach the workspace API:
_users/folder references in shell commands are blocked (prevents bypassing isolation)Workflow/folder references are blocked in chat mode (workflows have their own mode)- Write operations to folders outside the allowed list are rejected with an error message
| Variable | Default | Description |
|---|---|---|
MULTI_USER_MODE |
false |
Enable multi-user authentication |
AUTH_SECRET |
dev default | JWT signing secret (required in production) |
DEFAULT_USER_ID |
default-user |
Default user ID for single-user mode |
| Variable | Required | Description |
|---|---|---|
AUTH_USERS |
Yes | Comma-separated user:pass pairs |
WORKFLOW_USER_PERMISSIONS |
No | Comma-separated user:read/write/owner entries for workflow mode access |
WORKFLOW_OWNER_USERS |
No | Comma-separated users with owner workflow access |
WORKFLOW_WRITE_USERS |
No | Comma-separated users with builder/optimizer workflow access |
WORKFLOW_READ_USERS |
No | Comma-separated users limited to run mode |
| Variable | Required | Description |
|---|---|---|
COGNITO_USER_POOL_ID |
Yes | AWS Cognito User Pool ID |
COGNITO_CLIENT_ID |
Yes | Cognito App Client ID |
COGNITO_DOMAIN |
Yes | Cognito hosted UI domain |
AWS_REGION |
Yes | AWS region (e.g., us-east-1) |
| Variable | Required | Description |
|---|---|---|
SUPABASE_URL |
Yes | Supabase project URL |
SUPABASE_ANON_KEY |
Yes | Supabase anonymous key |
# No authentication required
MULTI_USER_MODE=falseMULTI_USER_MODE=true
AUTH_PROVIDERS=simple
AUTH_USERS=admin:admin123
AUTH_SECRET=dev-secret-change-meMULTI_USER_MODE=true
AUTH_PROVIDERS=cognito
AUTH_SECRET=your-production-secret
COGNITO_USER_POOL_ID=us-east-1_xxxxx
COGNITO_CLIENT_ID=xxxxxxxxx
COGNITO_DOMAIN=myapp.auth.us-east-1.amazoncognito.com
AWS_REGION=us-east-1GET /api/auth/modeResponse:
{
"multi_user_mode": true,
"providers": [
{"name": "simple", "type": "credentials"},
{"name": "cognito", "type": "oauth"}
]
}POST /api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "password123",
"provider": "simple"
}Response:
{
"token": "eyJhbGc...",
"user": {
"user_id": "abc123",
"username": "admin",
"provider": "simple"
}
}GET /api/auth/start?provider=cognitoResponse: Redirects to OAuth provider
GET /api/auth/callback?provider=cognito&code=xxx&state=xxxResponse: Exchanges code for app JWT and redirects to frontend
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/users |
List accounts (never hashes) plus the product ids this server can host |
| POST | /api/admin/users |
Create: username, optional email, password (≥8, blank = SSO only), admin, can_create, products |
| PUT | /api/admin/users/{id} |
Update any of the above, password resets it, disabled switches the account off. Admins cannot demote or disable themselves |
| DELETE | /api/admin/users/{id} |
Remove the record; the user's _users/<id> files are kept |
| POST | /api/auth/password |
Any user: current_password, new_password |
GET /api/auth/me now also returns is_admin and can_create; allowed_products is null for
unrestricted accounts, an array otherwise (an empty array means no products).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/workflow/access?workspace_path=Workflow/<folder> |
Owners, readers, and the caller's own level (my_access); legacy when nothing is recorded yet |
| PUT | /api/workflow/access |
{workspace_path, owners:[…], readers:[…]} — ids, usernames or emails; owners/admins only; at least one owner must remain |
| GET | /api/users/directory |
id, username, email of every enabled account, for the share picker |
Each entry in GET /api/workflows/manifests carries my_access; workflows the caller may not see
are omitted. Owners may edit, share and delete; readers get exactly the PLAT-262 read-only session
(chat, run, watch, inspect) and may trigger or stop schedules but not change them.
The workspace API uses the X-User-ID header for per-user folder routing:
GET /api/documents?folder=Chats
X-User-ID: user-abc123This header is automatically set by the agent API based on the authenticated user.
┌──────────┐ ┌──────────────┐
│ Frontend │ 1. GET /api/auth/mode │ Backend │
│ │◄──────────────────────────────────►│ │
│ │ 2. Show provider buttons │ │
│ │ │ │
│ │ 3a. POST /api/auth/login (simple) │ │
│ │──────────────────────────────────►│ │
│ │◄──────────────────────────────────│ │
│ │ 4a. JWT token │ │
│ │ │ │
│ │ 3b. GET /api/auth/start (OAuth) │ │
│ │──────────────────────────────────►│ │
│ │ 4b. Redirect to OAuth provider │ │
│ │ │ │
│ │ 5. OAuth callback with code │ │
│ │◄──────────────────────────────────│ │
│ │ 6. App JWT token │ │
└──────────┘ └──────────────┘
| File | Description |
|---|---|
agent_go/cmd/server/auth_middleware.go |
JWT validation, user context |
agent_go/cmd/server/auth_providers.go |
Provider interface, implementations |
agent_go/cmd/server/user_auth_routes.go |
Login, OAuth routes |
agent_go/pkg/workspace/client.go |
Workspace client with user ID |
agent_go/pkg/common/types.go |
Context keys including UserIDKey |
| File | Description |
|---|---|
workspace/utils/path.go |
Per-user path resolution, symlink setup, migration |
workspace/handlers/documents.go |
Document handlers with user routing |
workspace/handlers/shell.go |
Shell command handler with FolderGuard/Isolator integration |
workspace/security/isolator.go |
Mount namespace isolation (unshare) with read/write path control |
workspace/models/shell.go |
FolderGuardConfig struct definition |
workspace/server.go |
Startup migration, symlink creation |
| File | Description |
|---|---|
frontend/src/stores/useAuthStore.ts |
Auth state management |
frontend/src/pages/Login.tsx |
Login page with providers |
frontend/src/pages/AuthCallback.tsx |
OAuth callback handler |
- Tokens expire after 24 hours
- Signed with HMAC-SHA256
- Contains: user_id, username, email, provider
- Passwords are stored as argon2id hashes in
config/users.json(64MB, 3 passes, 2 lanes) AUTH_USERSis plain text in the environment and is only a bootstrap: its users are imported (hashed) on first start, after which the variable should be removed- Users change their own password via
POST /api/auth/password; admins reset via the admin API
- User IDs are sanitized (alphanumeric, hyphens, underscores only)
- Maximum length: 128 characters
- Invalid IDs fall back to
"default"
- All paths validated against directory traversal attacks
- Per-user folders isolated under
/_users/{userID}/ - Users cannot access other users' files through API
- Set
MULTI_USER_MODE=true - Configure at least one auth provider
- Existing files in
Chats/andDownloads/will be migrated to/_users/default/ - Existing users can continue with the same data after migration
On first startup with this feature:
- Server checks for existing
Chats/andDownloads/at root level - If found with content, moves them to
/_users/default/ - Creates per-user folder structure
- Shared folders remain unchanged
No manual intervention required - migration is automatic and one-time.
The multi-user isolation system has comprehensive test coverage across three test files.
| File | Tests | Scope |
|---|---|---|
workspace/utils/path_test.go |
38 | Path routing, user isolation, symlinks, migration |
workspace/handlers/documents_test.go |
8 | Document listing API, cross-user isolation |
workspace/security/isolator_test.go |
6 (multi-user) | FolderGuard mount scripts, sandbox profiles |
Tests for the core path routing logic that enforces per-user isolation.
User ID Validation:
TestIsValidUserID— Validates allowed characters (alphanumeric, hyphens, underscores), rejects special chars, path traversal attempts (../etc), and enforces max length (128 chars)TestSanitizeUserID— Empty/invalid user IDs fall back to"default"
Path Routing:
TestIsPerUserPath— Correctly classifiesChats/andDownloads/as per-user andskills/,Workflow/as sharedTestResolveUserPath— Per-user paths routed to_users/{userID}/, shared paths pass through unchanged,_users/direct access blocked, invalid/empty user IDs fall back to default, full internal paths sanitizedTestConvertToUserRelativePath— Strips_users/{userID}/prefix for API responsesTestSanitizeInputPath— Handles relative paths, full-path stripping,..cleaning
Cross-User Security:
TestCrossUserIsolation— User1 cannot access User2's files;_users/user2/Chatspath is blocked for User1; shared folders resolve identically for all users
Symlink Management:
TestEnsurePerUserSymlinks— Creates symlinks (Chats -> _users/default/Chats), idempotent on re-run, fixes wrong symlink targets, replaces empty directories with symlinks, skips non-empty directories to prevent data loss
Migration:
TestMigratePerUserFolders— Migrates root-levelChats/to_users/default/Chats/, skips already-migrated (symlinked) folders, skips empty folders, merges content in partial migration scenarios (root + user dirs both have files)
HTTP-level tests using httptest and a real Gin router to verify the document listing API.
Root Listing Security:
TestRootListingFiltersUsersDirectory—_users/directory never appears in root listing; per-user folders (Chats/,Downloads/) are injected from the user's isolated directoryTestRootListingWithDotFolder—folder=.parameter treated as root listing (same_users/filtering applies)
Per-User Isolation:
TestPerUserFolderIsolation— Default user seessession1.jsonin theirChats/but not User2'suser2-secret.json; User2 sees their own files but not the default user'sTestNoUserIDFallsToDefault— MissingX-User-IDheader falls back to"default"user
Cross-User Access Prevention:
TestDirectUsersAccessBlocked—folder=_usersrequest returns errorTestCrossUserAccessViaUsersPath—folder=_users/default/Chatsblocked for other users (prevents path-based cross-user data access)
Shared Folders:
TestSharedFoldersSameForAllUsers—skills/returns identical content regardless ofX-User-ID
Tests for the Linux mount namespace and macOS sandbox-exec isolation scripts.
Deny-List Mode (Mode 1) Symlink Fixup:
TestDenyListSymlinkFixup— When_users/is hidden with tmpfs, symlinks likeChats -> _users/default/Chatswould break. Verifies that the Linux mount script preserves the workspace via bind-mount and re-mounts symlink targets after tmpfs. Verifies the macOS sandbox profile adds explicit allow rules for symlink targets within the denied path.TestDenyListNoSymlinks— When no symlinks point into blocked paths, no unnecessary workspace preservation occurs (simpler script)TestDenyListWithMultiUser— With multiple users (default,alice,bob), only the current user's symlink targets are exposed in the mount script. Alice and bob's directories remain hidden.
Environment Isolation:
TestEnvironmentIsolation— Secrets (DATABASE_URL,API_KEY) set in the parent process are NOT leaked to subprocess environment; safe PATH is present
# All multi-user isolation tests
go test ./utils/ ./handlers/ ./security/ -v
# Path routing tests only
go test ./utils/ -v
# Document API tests only
go test ./handlers/ -run "TestRootListing|TestPerUser|TestShared|TestDirectUsers|TestCrossUser|TestNoUser" -v
# FolderGuard/Isolator tests only
go test ./security/ -run "TestDenyList" -vAll commands should be run from the workspace/ directory.