Skip to content

Commit 52e2417

Browse files
mistercrunchClaude Codeclaude
authored
feat(unix): centralize Unix integration service in @agor/core (#431)
* feat(unix): centralize Unix integration service in @agor/core Moves Unix user/group/symlink management to packages/core for shared use by CLI and daemon. Introduces CommandExecutor abstraction for privilege escalation. Key changes: - Add CommandExecutor interface with DirectExecutor, SudoCliExecutor, NoOpExecutor - Add UnixIntegrationService with worktree groups, user management, symlinks - Add user-manager.ts with Unix user utilities and command strings - Add symlink-manager.ts with symlink utilities for worktree access - Refactor daemon to use core service via factory pattern - Wire up hooks in worktree-owners for auto group membership - Wire up hooks in users service for auto Unix user creation - Add CLI admin commands: ensure-user, delete-user, create/remove-symlink, sync-user-symlinks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * feat(unix): add agor_users group for user namespace containment - Add AGOR_USERS_GROUP constant for managed user identification - Add ensureAgorUsersGroup() to create group if not exists - Add addUserToAgorUsersGroup/removeUserFromAgorUsersGroup methods - Add isAgorManagedUser() to check group membership - ensureUnixUser now adds users to agor_users group - deleteUnixUser now removes users from agor_users group This enables natural usernames (max instead of agor_max) while maintaining security containment - daemon can only impersonate users that are members of the agor_users group. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * docs: add Full Multiplayer Mode guide for RBAC + Unix isolation Adds comprehensive documentation for running Agor in full multiplayer mode with Unix-level isolation: - Overview of shared development environments and isolation benefits - Security considerations (daemon privileges, API key exposure) - Recommendations (VPN, trusted users, key rotation) - Setup guide: PostgreSQL, volumes, sudoers, observability - Permission levels reference (view/prompt/all) - Troubleshooting common issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * feat(unix): centralize impersonation logic with unit tests - Add resolveUnixUserForImpersonation() for centralized mode-aware logic - Add validateResolvedUnixUser() for user existence validation - Refactor terminals.ts and index.ts to use centralized utilities - Add comprehensive unit tests (111 tests) for Unix utilities: - user-manager.test.ts: username gen/parse, validation, impersonation - group-manager.test.ts: group names, permissions, command builders - symlink-manager.test.ts: paths, symlink info, command builders The impersonation logic now consistently handles all 4 Unix user modes (simple, insulated, opportunistic, strict) across terminal sessions and executor spawning. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix(unix): address reviewer findings for RBAC terminal support - Remove CLI executor mode (not wired up, SudoCliExecutor expects `agor admin` subcommands that don't exist yet) - Fix env file permissions: chown to impersonated user so they can source the file (was 0600 owned by daemon, unreadable after sudo -u) - Pass homeBase to UnixUserCommands.createUser() so user homes are created in configured location, not always /home 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * chore(docker): improve postgres compose and entrypoint reliability - Remove SSH port exposure (use docker exec + sudo su instead) - Add comment explaining how to test as alice/bob - Wait for db type definitions in entrypoint (needed for migrations) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude Code <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 1f94ca8 commit 52e2417

22 files changed

+3654
-411
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Admin Command: Create Worktree Symlink
3+
*
4+
* PRIVILEGED OPERATION - Must be called via sudo
5+
*
6+
* Creates a symlink in a user's ~/agor/worktrees directory pointing to a worktree.
7+
* This command is designed to be called by the daemon via `sudo agor admin create-symlink`.
8+
*
9+
* @see context/guides/rbac-and-unix-isolation.md
10+
*/
11+
12+
import { execSync } from 'node:child_process';
13+
import {
14+
AGOR_HOME_BASE,
15+
getWorktreeSymlinkPath,
16+
isValidUnixUsername,
17+
SymlinkCommands,
18+
} from '@agor/core/unix';
19+
import { Command, Flags } from '@oclif/core';
20+
21+
export default class CreateSymlink extends Command {
22+
static override description = 'Create a worktree symlink in user home directory (admin only)';
23+
24+
static override examples = [
25+
'<%= config.bin %> <%= command.id %> --username alice --worktree-name my-feature --worktree-path /var/agor/worktrees/abc123',
26+
];
27+
28+
static override flags = {
29+
username: Flags.string({
30+
char: 'u',
31+
description: 'Unix username (owner of symlink)',
32+
required: true,
33+
}),
34+
'worktree-name': Flags.string({
35+
char: 'n',
36+
description: 'Worktree name/slug (symlink name)',
37+
required: true,
38+
}),
39+
'worktree-path': Flags.string({
40+
char: 'p',
41+
description: 'Absolute path to worktree directory (symlink target)',
42+
required: true,
43+
}),
44+
'home-base': Flags.string({
45+
description: 'Base directory for home directories',
46+
default: AGOR_HOME_BASE,
47+
}),
48+
};
49+
50+
public async run(): Promise<void> {
51+
const { flags } = await this.parse(CreateSymlink);
52+
const { username } = flags;
53+
const worktreeName = flags['worktree-name'];
54+
const worktreePath = flags['worktree-path'];
55+
const homeBase = flags['home-base'];
56+
57+
// Validate username
58+
if (!isValidUnixUsername(username)) {
59+
this.error(`Invalid Unix username format: ${username}`);
60+
}
61+
62+
// Validate worktree path is absolute
63+
if (!worktreePath.startsWith('/')) {
64+
this.error(`Worktree path must be absolute: ${worktreePath}`);
65+
}
66+
67+
const linkPath = getWorktreeSymlinkPath(username, worktreeName, homeBase);
68+
69+
// Check if symlink already exists and points to same target
70+
try {
71+
const existingTarget = execSync(SymlinkCommands.readSymlink(linkPath), {
72+
encoding: 'utf-8',
73+
}).trim();
74+
75+
if (existingTarget === worktreePath) {
76+
this.log(`✅ Symlink already exists: ${linkPath} -> ${worktreePath}`);
77+
return;
78+
}
79+
// Symlink exists but points elsewhere - will be replaced
80+
this.log(`ℹ️ Updating symlink (was: ${existingTarget})`);
81+
} catch {
82+
// Symlink doesn't exist, will create
83+
}
84+
85+
// Create symlink with proper ownership
86+
try {
87+
execSync(SymlinkCommands.createSymlinkWithOwnership(worktreePath, linkPath, username), {
88+
stdio: 'inherit',
89+
});
90+
this.log(`✅ Created symlink: ${linkPath} -> ${worktreePath}`);
91+
} catch (error) {
92+
this.error(`Failed to create symlink: ${error}`);
93+
}
94+
}
95+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Admin Command: Delete Unix User
3+
*
4+
* PRIVILEGED OPERATION - Must be called via sudo
5+
*
6+
* Deletes a Unix user. Optionally removes their home directory.
7+
* This command is designed to be called by the daemon via `sudo agor admin delete-user`.
8+
*
9+
* @see context/guides/rbac-and-unix-isolation.md
10+
*/
11+
12+
import { execSync } from 'node:child_process';
13+
import { isValidUnixUsername, UnixUserCommands } from '@agor/core/unix';
14+
import { Command, Flags } from '@oclif/core';
15+
16+
export default class DeleteUser extends Command {
17+
static override description = 'Delete a Unix user (admin only)';
18+
19+
static override examples = [
20+
'<%= config.bin %> <%= command.id %> --username agor_03b62447',
21+
'<%= config.bin %> <%= command.id %> --username agor_03b62447 --delete-home',
22+
];
23+
24+
static override flags = {
25+
username: Flags.string({
26+
char: 'u',
27+
description: 'Unix username to delete',
28+
required: true,
29+
}),
30+
'delete-home': Flags.boolean({
31+
description: 'Also delete the user home directory',
32+
default: false,
33+
}),
34+
};
35+
36+
public async run(): Promise<void> {
37+
const { flags } = await this.parse(DeleteUser);
38+
const { username } = flags;
39+
const deleteHome = flags['delete-home'];
40+
41+
// Validate username format
42+
if (!isValidUnixUsername(username)) {
43+
this.error(`Invalid Unix username format: ${username}`);
44+
}
45+
46+
// Check if user exists
47+
try {
48+
execSync(UnixUserCommands.userExists(username), { stdio: 'ignore' });
49+
} catch {
50+
this.log(`✅ Unix user ${username} does not exist (nothing to do)`);
51+
return;
52+
}
53+
54+
// Delete the user
55+
try {
56+
if (deleteHome) {
57+
execSync(UnixUserCommands.deleteUserWithHome(username), { stdio: 'inherit' });
58+
this.log(`✅ Deleted Unix user ${username} and home directory`);
59+
} else {
60+
execSync(UnixUserCommands.deleteUser(username), { stdio: 'inherit' });
61+
this.log(`✅ Deleted Unix user ${username} (home directory preserved)`);
62+
}
63+
} catch (error) {
64+
this.error(`Failed to delete user ${username}: ${error}`);
65+
}
66+
}
67+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Admin Command: Ensure Unix User Exists
3+
*
4+
* PRIVILEGED OPERATION - Must be called via sudo
5+
*
6+
* Creates a Unix user if it doesn't exist, with home directory and ~/agor/worktrees setup.
7+
* This command is designed to be called by the daemon via `sudo agor admin ensure-user`.
8+
*
9+
* @see context/guides/rbac-and-unix-isolation.md
10+
*/
11+
12+
import { execSync } from 'node:child_process';
13+
import { AGOR_HOME_BASE, isValidUnixUsername, UnixUserCommands } from '@agor/core/unix';
14+
import { Command, Flags } from '@oclif/core';
15+
16+
export default class EnsureUser extends Command {
17+
static override description = 'Ensure a Unix user exists with proper Agor setup (admin only)';
18+
19+
static override examples = [
20+
'<%= config.bin %> <%= command.id %> --username agor_03b62447',
21+
'<%= config.bin %> <%= command.id %> --username alice --home-base /home',
22+
];
23+
24+
static override flags = {
25+
username: Flags.string({
26+
char: 'u',
27+
description: 'Unix username to create/ensure',
28+
required: true,
29+
}),
30+
'home-base': Flags.string({
31+
description: 'Base directory for home directories',
32+
default: AGOR_HOME_BASE,
33+
}),
34+
};
35+
36+
public async run(): Promise<void> {
37+
const { flags } = await this.parse(EnsureUser);
38+
const { username } = flags;
39+
const homeBase = flags['home-base'];
40+
41+
// Validate username format
42+
if (!isValidUnixUsername(username)) {
43+
this.error(`Invalid Unix username format: ${username}`);
44+
}
45+
46+
// Check if user already exists
47+
try {
48+
execSync(UnixUserCommands.userExists(username), { stdio: 'ignore' });
49+
this.log(`✅ Unix user ${username} already exists`);
50+
51+
// Ensure ~/agor/worktrees directory exists
52+
try {
53+
execSync(UnixUserCommands.setupWorktreesDir(username, homeBase), { stdio: 'inherit' });
54+
this.log(`✅ Ensured ~/agor/worktrees directory for ${username}`);
55+
} catch (error) {
56+
this.warn(`Failed to setup worktrees directory: ${error}`);
57+
}
58+
59+
return;
60+
} catch {
61+
// User doesn't exist, create it
62+
}
63+
64+
// Create the user
65+
try {
66+
execSync(UnixUserCommands.createUser(username, '/bin/bash', homeBase), { stdio: 'inherit' });
67+
this.log(`✅ Created Unix user: ${username}`);
68+
69+
// Setup ~/agor/worktrees directory
70+
execSync(UnixUserCommands.setupWorktreesDir(username, homeBase), { stdio: 'inherit' });
71+
this.log(`✅ Created ~/agor/worktrees directory for ${username}`);
72+
} catch (error) {
73+
this.error(`Failed to create user ${username}: ${error}`);
74+
}
75+
}
76+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Admin Command: Remove Worktree Symlink
3+
*
4+
* PRIVILEGED OPERATION - Must be called via sudo
5+
*
6+
* Removes a symlink from a user's ~/agor/worktrees directory.
7+
* This command is designed to be called by the daemon via `sudo agor admin remove-symlink`.
8+
*
9+
* @see context/guides/rbac-and-unix-isolation.md
10+
*/
11+
12+
import { execSync } from 'node:child_process';
13+
import {
14+
AGOR_HOME_BASE,
15+
getWorktreeSymlinkPath,
16+
isValidUnixUsername,
17+
SymlinkCommands,
18+
} from '@agor/core/unix';
19+
import { Command, Flags } from '@oclif/core';
20+
21+
export default class RemoveSymlink extends Command {
22+
static override description = 'Remove a worktree symlink from user home directory (admin only)';
23+
24+
static override examples = [
25+
'<%= config.bin %> <%= command.id %> --username alice --worktree-name my-feature',
26+
];
27+
28+
static override flags = {
29+
username: Flags.string({
30+
char: 'u',
31+
description: 'Unix username (owner of symlink)',
32+
required: true,
33+
}),
34+
'worktree-name': Flags.string({
35+
char: 'n',
36+
description: 'Worktree name/slug (symlink name)',
37+
required: true,
38+
}),
39+
'home-base': Flags.string({
40+
description: 'Base directory for home directories',
41+
default: AGOR_HOME_BASE,
42+
}),
43+
};
44+
45+
public async run(): Promise<void> {
46+
const { flags } = await this.parse(RemoveSymlink);
47+
const { username } = flags;
48+
const worktreeName = flags['worktree-name'];
49+
const homeBase = flags['home-base'];
50+
51+
// Validate username
52+
if (!isValidUnixUsername(username)) {
53+
this.error(`Invalid Unix username format: ${username}`);
54+
}
55+
56+
const linkPath = getWorktreeSymlinkPath(username, worktreeName, homeBase);
57+
58+
// Check if symlink exists
59+
try {
60+
execSync(SymlinkCommands.symlinkExists(linkPath), { stdio: 'ignore' });
61+
} catch {
62+
this.log(`✅ Symlink does not exist: ${linkPath} (nothing to do)`);
63+
return;
64+
}
65+
66+
// Remove symlink
67+
try {
68+
execSync(SymlinkCommands.removeSymlink(linkPath), { stdio: 'inherit' });
69+
this.log(`✅ Removed symlink: ${linkPath}`);
70+
} catch (error) {
71+
this.error(`Failed to remove symlink: ${error}`);
72+
}
73+
}
74+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Admin Command: Sync User Symlinks
3+
*
4+
* PRIVILEGED OPERATION - Must be called via sudo
5+
*
6+
* Cleans up broken symlinks in a user's ~/agor/worktrees directory.
7+
* This command is designed to be called by the daemon via `sudo agor admin sync-user-symlinks`.
8+
*
9+
* @see context/guides/rbac-and-unix-isolation.md
10+
*/
11+
12+
import { execSync } from 'node:child_process';
13+
import {
14+
AGOR_HOME_BASE,
15+
getUserWorktreesDir,
16+
isValidUnixUsername,
17+
SymlinkCommands,
18+
} from '@agor/core/unix';
19+
import { Command, Flags } from '@oclif/core';
20+
21+
export default class SyncUserSymlinks extends Command {
22+
static override description = 'Clean up broken symlinks in user worktrees directory (admin only)';
23+
24+
static override examples = ['<%= config.bin %> <%= command.id %> --username alice'];
25+
26+
static override flags = {
27+
username: Flags.string({
28+
char: 'u',
29+
description: 'Unix username',
30+
required: true,
31+
}),
32+
'home-base': Flags.string({
33+
description: 'Base directory for home directories',
34+
default: AGOR_HOME_BASE,
35+
}),
36+
};
37+
38+
public async run(): Promise<void> {
39+
const { flags } = await this.parse(SyncUserSymlinks);
40+
const { username } = flags;
41+
const homeBase = flags['home-base'];
42+
43+
// Validate username
44+
if (!isValidUnixUsername(username)) {
45+
this.error(`Invalid Unix username format: ${username}`);
46+
}
47+
48+
const worktreesDir = getUserWorktreesDir(username, homeBase);
49+
50+
// Check if directory exists
51+
try {
52+
execSync(SymlinkCommands.pathExists(worktreesDir), { stdio: 'ignore' });
53+
} catch {
54+
this.log(`✅ Worktrees directory does not exist: ${worktreesDir} (nothing to do)`);
55+
return;
56+
}
57+
58+
// Remove broken symlinks
59+
try {
60+
execSync(SymlinkCommands.removeBrokenSymlinks(worktreesDir), { stdio: 'inherit' });
61+
this.log(`✅ Cleaned up broken symlinks in: ${worktreesDir}`);
62+
} catch (error) {
63+
this.error(`Failed to sync symlinks: ${error}`);
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)