Files
platform/src/servers/os-user.test.ts
T
pastilhas 069dd4b23f provisioning: re-assert the acl mask, last
on a fresh server an Admin granted files, chat and terminal reached none of
them. the grants were right, the linux account existed, the named acl entries
were on the home — and the file browser still raised EACCES.

a posix acl mask is not independent of the mode bits. chmod recomputes it from
the GROUP bits, so a directory closed to 700 gets `mask::---`, which clamps
every named entry to nothing. the platform's grant survived as
`user:pastilhas:rwx #effective:---`: present, correct-looking, and inert. it
reads as a working acl in every place anyone thought to check.

ensureOsUser already knows this and orders itself correctly — chmod, then
setfacl, with a comment saying why. verified that order is right by running
both ways on the affected host: chmod-then-setfacl gives mask::rwx,
setfacl-then-chmod gives mask::---.

what it cannot account for is the steps AFTER it, each of which creates a
directory and then closes it: .ssh at 700 from the key setup, .config and
.local from the claude installer. each inherits the default acl at creation
and clamps its own mask a moment later. the correlation on the live host was
exact — every 770 directory had mask::rwx and every 700 one had mask::---.

so the mask is re-asserted at the very end of provisionOsAccount, recursively,
and nothing else is touched: the named entries were never wrong, and
re-granting them here would hide it if they ever were.

non-fatal if it fails, like every other side effect in that function. but it
is the difference between an account that works and one that only appears to.

repaired on the affected server by hand first, and confirmed: home readable by
the service user, zero `effective:---` entries left under it.
2026-08-19 23:03:14 +00:00

279 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from 'bun:test';
import { chmod, mkdir, mkdtemp, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findReadableSecrets, osUserNameFor, runAsArgv, secureRuntimeSecrets, maskArgvFor } from './os-user';
// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun
// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database
// and is exercised by hand — see docs/per-user-linux-accounts.md.
describe('Bun.spawn uid', () => {
// THE pin. `Bun.spawn` accepts `uid`/`gid` at RUNTIME and silently ignores them, so a privilege drop
// written the obvious way runs as the parent while looking correct. This test exists so that:
//
// - nobody "simplifies" runAs into a uid option, and
// - if Bun ever implements it, this fails and tells us we may.
//
// Bun's own types do not declare `uid`, so typed code cannot reach this by accident — hence the cast,
// which stands in for the ways you WOULD reach it: a spread of untyped config, or an `as any` in a
// hurry. The runtime is what silently accepts it, and the runtime is what this pins.
//
// Skipped when running as root, where setuid would actually be permitted and the observation changes.
test.skipIf(process.getuid?.() === 0)('is ignored at runtime — this is why runAs exists', async () => {
const nobody = 65534;
expect(process.getuid?.()).not.toBe(nobody);
const options = { uid: nobody, gid: nobody, stdout: 'pipe', stderr: 'pipe' } as unknown as {
stdout: 'pipe';
stderr: 'pipe';
};
const proc = Bun.spawn(['id', '-u'], options);
const seen = (await new Response(proc.stdout).text()).trim();
const code = await proc.exited;
// If this ever fails, read the assertion rather than fixing it: either the child ran as `nobody`
// (Bun now honours the option) or it refused with EPERM (Bun now tries). Both are good news.
expect(code).toBe(0);
expect(seen).toBe(String(process.getuid?.()));
expect(seen).not.toBe(String(nobody));
});
});
/** Whether this machine can actually drop privileges. See the sudo note in runAsArgv. */
const canSudo = await (async () => {
const proc = Bun.spawn(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' });
return (await proc.exited) === 0;
})();
describe('runAsArgv', () => {
test('wraps the command in sudo + setpriv with real ids, groups and a reset environment', () => {
expect(runAsArgv('officer_ana', ['zsh', '-i'])).toEqual([
'sudo',
'-n',
'setpriv',
'--reuid=officer_ana',
'--regid=officer_ana',
'--init-groups',
'--reset-env',
'--',
'zsh',
'-i',
]);
});
// --init-groups and --reset-env are not decoration: without the first the process keeps the owner's
// supplementary groups, and without the second it inherits everything Bun loaded from .env.
test('never omits --init-groups or --reset-env', () => {
const argv = runAsArgv('officer_ana', ['true']);
expect(argv).toContain('--init-groups');
expect(argv).toContain('--reset-env');
});
test('refuses an empty user or command rather than running as the owner', () => {
expect(() => runAsArgv('', ['true'])).toThrow();
expect(() => runAsArgv('officer_ana', [])).toThrow();
});
// Proves the argv composes and runs end to end. Targets the CURRENT account so no test user has to be
// created, which still exercises sudo, setpriv, initgroups and the env reset.
//
// Skipped where passwordless sudo is unavailable — that is a machine that cannot run this feature at
// all, and a red test there would say "the code is broken" instead of "this host is not set up".
test.skipIf(!canSudo)('runs the command as the requested account', async () => {
const me = process.getuid?.() ?? 0;
const proc = Bun.spawn(runAsArgv(String(me), ['id', '-u']), { stdout: 'pipe', stderr: 'pipe' });
const out = (await new Response(proc.stdout).text()).trim();
const err = (await new Response(proc.stderr).text()).trim();
expect(await proc.exited, `setpriv failed: ${err}`).toBe(0);
expect(out).toBe(String(me));
});
// The property the whole feature rests on: the platform's environment does not cross the boundary. This
// process is started by PM2 in the platform directory, so Bun has auto-loaded `.env` into it — the JWT
// secret and POSTGRES_URL are in `process.env` right now. A member's shell must not see them.
test.skipIf(!canSudo)('does not pass the platform environment through', async () => {
const me = process.getuid?.() ?? 0;
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "[${OFFICER_LEAK_PROBE:-unset}]"']), {
env: { ...process.env, OFFICER_LEAK_PROBE: 'this-must-not-cross' },
stdout: 'pipe',
stderr: 'pipe',
});
const out = (await new Response(proc.stdout).text()).trim();
expect(await proc.exited).toBe(0);
expect(out).toBe('[unset]');
});
// …and HOME is the target account's, not the caller's. This is what makes a member's shell and their
// agent's config land in their own directory rather than the owner's.
test.skipIf(!canSudo)('sets HOME from the target account, not the caller', async () => {
const me = process.getuid?.() ?? 0;
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "$HOME"']), { stdout: 'pipe', stderr: 'pipe' });
const out = (await new Response(proc.stdout).text()).trim();
expect(await proc.exited).toBe(0);
expect(out).toBeTruthy();
// Read from passwd rather than inherited: `--reset-env` cleared the caller's HOME before setting it.
const passwd = Bun.spawn(['sh', '-c', `getent passwd ${me} | cut -d: -f6`], { stdout: 'pipe' });
expect(out).toBe((await new Response(passwd.stdout).text()).trim());
});
});
describe('osUserNameFor', () => {
// The chosen username, verbatim — so `whoami` in a member's terminal says who they are. Measured on this
// host: useradd accepts dots, hyphens, underscores and uppercase, i.e. everything validateUsername lets
// through.
test('uses the chosen username as-is', () => {
expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe('ana');
expect(osUserNameFor({ username: 'ana.silva', email: 'a@b.com' })).toBe('ana.silva');
expect(osUserNameFor({ username: 'Ana-Silva_2', email: 'a@b.com' })).toBe('Ana-Silva_2');
});
test('falls back to the email local part when there is no username', () => {
expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe('ana.silva');
expect(osUserNameFor({ username: ' ', email: 'Ana.Silva@example.com' })).toBe('ana.silva');
});
test('stays within the 32-character limit useradd enforces', () => {
expect(osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' })).toHaveLength(32);
});
// No longer defended by a prefix, so it must be defended by adoption rules instead: `ensureOsUser`
// refuses a name whose existing passwd home is not the one we are about to confine, and refuses any uid
// below 1000 outright. This test records that the NAME itself is no longer the protection.
test('does not neutralise a dangerous name — that is ensureOsUser-s job now', () => {
expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe('root');
});
});
describe('findReadableSecrets', () => {
test('reports a group- or world-readable .env, and nothing when it is 600', async () => {
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
const env = join(dir, '.env');
await writeFile(env, 'JWT_SECRET=x\n');
await chmod(env, 0o644);
expect(await findReadableSecrets(dir)).toEqual([env]);
// Group-only still counts: a member's supplementary groups are not ours to predict.
await chmod(env, 0o640);
expect(await findReadableSecrets(dir)).toEqual([env]);
await chmod(env, 0o600);
expect(await findReadableSecrets(dir)).toEqual([]);
});
test('an absent .env is not a finding', async () => {
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
expect(await findReadableSecrets(dir)).toEqual([]);
});
});
/*
The proxy secret IS the owner's Claude Max subscription.
`officer-anthropic-proxy` swaps a matching `x-api-key` for the owner's OAuth bearer and forwards to
Anthropic, on a loopback port every local account can reach. So these assertions are not about tidy file
modes — they are the difference between a member's turns being billed to their own account (which
`spawn-as-member.ts` goes to considerable lengths to ensure) and a member being able to help themselves
to the owner's.
It shipped world-readable: default umask 002, no mode ever set, verified by reading the live secret as
`nobody` on 2026-08-17.
*/
describe('secureRuntimeSecrets', () => {
async function fixture(mode: number) {
const root = await mkdtemp(join(tmpdir(), 'officer-data-'));
const dir = join(root, 'sidecar');
await mkdir(dir, { recursive: true });
const file = join(dir, 'claude-state.json');
await writeFile(file, JSON.stringify({ proxySecret: 'sk-ant-api03-test', claudeSessions: {} }));
await chmod(file, mode);
await chmod(dir, 0o755);
return { root, dir, file };
}
test('closes a world-readable proxy secret and its directory', async () => {
const { root, dir, file } = await fixture(0o664);
const result = await secureRuntimeSecrets(root);
expect(result.failed).toEqual([]);
expect(result.repaired).toContain(file);
expect(result.repaired).toContain(dir);
// The mode is the assertion, not the return value: a function that reports success while leaving the
// file readable is the exact failure this exists to prevent.
expect((await stat(file)).mode & 0o777).toBe(0o600);
expect((await stat(dir)).mode & 0o777).toBe(0o700);
});
test('group-readable counts too — a member’s supplementary groups are not ours to predict', async () => {
const { root, file } = await fixture(0o640);
const result = await secureRuntimeSecrets(root);
expect(result.repaired).toContain(file);
expect((await stat(file)).mode & 0o777).toBe(0o600);
});
test('is idempotent: a second boot repairs nothing and reports nothing', async () => {
const { root } = await fixture(0o664);
await secureRuntimeSecrets(root);
// Not merely "no error" — an empty list, so the log on every subsequent restart stays silent and the
// warning keeps meaning something when it does appear.
expect(await secureRuntimeSecrets(root)).toEqual({ repaired: [], failed: [] });
});
test('covers the per-account copies, not just the proxy’s own', async () => {
const { root } = await fixture(0o600);
const memberDir = join(root, 'someone@example.com', 'sidecar');
await mkdir(memberDir, { recursive: true });
const memberFile = join(memberDir, 'claude-state.json');
await writeFile(memberFile, '{}');
await chmod(memberFile, 0o664);
const result = await secureRuntimeSecrets(root);
// These hold no live secret today, but they map session keys to transcript uuids for a NAMED account,
// which is not something one member should be able to enumerate about another.
expect(result.repaired).toContain(memberFile);
expect((await stat(memberFile)).mode & 0o777).toBe(0o600);
});
test('an install with nothing written yet is not an error', async () => {
const root = await mkdtemp(join(tmpdir(), 'officer-data-'));
expect(await secureRuntimeSecrets(root)).toEqual({ repaired: [], failed: [] });
});
});
describe('ACL mask, after everything else', () => {
// The regression: a chmod recomputes the ACL mask from the GROUP bits, so a directory closed to 700
// after the platform's grant was set ends up `mask::---`, clamping `user:<service>:rwx` to nothing.
// The entry is still there and reads as correct; it just does not grant anything.
//
// This asserts the shape of the repair rather than the filesystem effect — the effect needs root, an
// acl-capable filesystem and a second uid, none of which a unit test has. It was verified live on a
// real member's home instead (2026-08-19): READABLE afterwards, and zero `effective:---` entries left.
test('re-asserts the mask recursively, and touches nothing else', () => {
const argv = maskArgvFor('/data/someone@example.com/home');
expect(argv).toEqual([
'sudo',
'-n',
'setfacl',
'-R',
'-m',
'm::rwx',
'-m',
'd:m::rwx',
'/data/someone@example.com/home',
]);
// Recursive, because every subdirectory created-then-closed after the original grant has the same
// clamped mask — `.ssh`, `.config` and `.local` all did.
expect(argv).toContain('-R');
// The default mask too, or the next directory the member creates inherits the broken state.
expect(argv).toContain('d:m::rwx');
// No named entries: those were always right, and re-granting them here would hide it if they were not.
expect(argv.some((a: string) => a.startsWith('u:'))).toBe(false);
});
});