Let computers push from project folders through the GitHub broker (#217)
Two gaps kept agents from pushing on a containerized supervisor: - The git and gh shims called rakazo-broker by name, and login shells in the computer reset PATH, so the broker was "not found". The shims now call it next to their own resolved path, and /usr/bin/gh is a shim too. - A project folder outside the supervisor container's mounts is invisible to it, so broker git ran against an empty directory. Such calls now run in a one-off computer-image container that binds the folder. Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a27cdf733c
commit
1611fe5fa2
@ -52,10 +52,13 @@ WORKDIR /home/rakazo
|
|||||||
# --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would
|
# --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would
|
||||||
# otherwise leave these files unreadable to USER 1000 at runtime.
|
# otherwise leave these files unreadable to USER 1000 at runtime.
|
||||||
COPY --chmod=755 bin/rakazo-git bin/rakazo-gh bin/rakazo-broker /usr/local/lib/rakazo/bin/
|
COPY --chmod=755 bin/rakazo-git bin/rakazo-gh bin/rakazo-broker /usr/local/lib/rakazo/bin/
|
||||||
# /usr/bin/git is the shim too, so calling git by absolute path still goes through the broker.
|
# /usr/bin/git and /usr/bin/gh are the shims too, so absolute paths and login shells that reset
|
||||||
|
# PATH still go through the broker.
|
||||||
RUN mkdir -p /usr/local/libexec/rakazo \
|
RUN mkdir -p /usr/local/libexec/rakazo \
|
||||||
&& mv /usr/bin/git /usr/local/libexec/rakazo/git \
|
&& mv /usr/bin/git /usr/local/libexec/rakazo/git \
|
||||||
|
&& mv /usr/bin/gh /usr/local/libexec/rakazo/gh \
|
||||||
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/bin/git \
|
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/bin/git \
|
||||||
|
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/bin/gh \
|
||||||
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/local/lib/rakazo/bin/git \
|
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/local/lib/rakazo/bin/git \
|
||||||
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/local/lib/rakazo/bin/gh
|
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/local/lib/rakazo/bin/gh
|
||||||
COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer
|
COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REAL_GH=/usr/bin/gh
|
REAL_GH=/usr/local/libexec/rakazo/gh
|
||||||
if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then
|
if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then
|
||||||
exec "$REAL_GH" "$@"
|
exec "$REAL_GH" "$@"
|
||||||
fi
|
fi
|
||||||
@ -9,4 +9,4 @@ if [[ ! "$joined" =~ (^|[[:space:]])pr([[:space:]]|$) ]]; then
|
|||||||
exec "$REAL_GH" "$@"
|
exec "$REAL_GH" "$@"
|
||||||
fi
|
fi
|
||||||
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' "$@")"
|
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' "$@")"
|
||||||
exec rakazo-broker v1/gh "$payload"
|
exec "$(dirname "$(readlink -f "$0")")/rakazo-broker" v1/gh "$payload"
|
||||||
|
|||||||
@ -23,7 +23,7 @@ done
|
|||||||
case "${args[i]:-}" in
|
case "${args[i]:-}" in
|
||||||
push | fetch | pull | clone | ls-remote)
|
push | fetch | pull | clone | ls-remote)
|
||||||
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["git"]+sys.argv[2:],"cwd":sys.argv[1]}))' "$cwd" "${args[@]:i}")"
|
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["git"]+sys.argv[2:],"cwd":sys.argv[1]}))' "$cwd" "${args[@]:i}")"
|
||||||
exec rakazo-broker v1/git "$payload"
|
exec "$(dirname "$(readlink -f "$0")")/rakazo-broker" v1/git "$payload"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
exec "$REAL_GIT" "$@"
|
exec "$REAL_GIT" "$@"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { execFileSync, spawnSync } from "node:child_process";
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
import { copyFileSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@ -8,17 +8,23 @@ import { describe, expect, it } from "vitest";
|
|||||||
const shimPath = (name: string) =>
|
const shimPath = (name: string) =>
|
||||||
fileURLToPath(new URL(`../../computer/bin/${name}`, import.meta.url));
|
fileURLToPath(new URL(`../../computer/bin/${name}`, import.meta.url));
|
||||||
|
|
||||||
/** Runs the computer's git shim with a stub broker that echoes what it would send. */
|
/**
|
||||||
|
* Runs the computer's git shim through a symlink, as /usr/bin/git does, next to a stub broker that
|
||||||
|
* echoes what it would send. The stub is not on PATH: login shells reset PATH in the computer.
|
||||||
|
*/
|
||||||
function brokerPayload(args: string[], cwd: string, shim = "rakazo-git") {
|
function brokerPayload(args: string[], cwd: string, shim = "rakazo-git") {
|
||||||
const bin = mkdtempSync(path.join(tmpdir(), "rakazo-git-shim-"));
|
const bin = mkdtempSync(path.join(tmpdir(), "rakazo-git-shim-"));
|
||||||
|
copyFileSync(shimPath(shim), path.join(bin, shim));
|
||||||
writeFileSync(path.join(bin, "rakazo-broker"), '#!/bin/sh\nprintf "%s\\n%s" "$1" "$2"\n', {
|
writeFileSync(path.join(bin, "rakazo-broker"), '#!/bin/sh\nprintf "%s\\n%s" "$1" "$2"\n', {
|
||||||
mode: 0o755,
|
mode: 0o755,
|
||||||
});
|
});
|
||||||
const output = execFileSync("bash", [shimPath(shim), ...args], {
|
const linked = mkdtempSync(path.join(tmpdir(), "rakazo-git-link-"));
|
||||||
|
symlinkSync(path.join(bin, shim), path.join(linked, shim));
|
||||||
|
const output = execFileSync("bash", [path.join(linked, shim), ...args], {
|
||||||
cwd,
|
cwd,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: {
|
env: {
|
||||||
PATH: `${bin}:${process.env.PATH}`,
|
PATH: process.env.PATH,
|
||||||
RAKAZO_GITHUB_BROKER_URL: "http://host.docker.internal:7091/internal/github-broker",
|
RAKAZO_GITHUB_BROKER_URL: "http://host.docker.internal:7091/internal/github-broker",
|
||||||
RAKAZO_GITHUB_BROKER_RUN_TOKEN: "rgb1.test",
|
RAKAZO_GITHUB_BROKER_RUN_TOKEN: "rgb1.test",
|
||||||
},
|
},
|
||||||
|
|||||||
100
infra/sandboxes/supervisor/src/github-broker-helper.ts
Normal file
100
infra/sandboxes/supervisor/src/github-broker-helper.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import type Docker from "dockerode";
|
||||||
|
import type {
|
||||||
|
BrokerCommandInput,
|
||||||
|
BrokerCommandResult,
|
||||||
|
BrokerOutsideRunner,
|
||||||
|
} from "./github-broker.js";
|
||||||
|
|
||||||
|
// The computer image keeps the real binaries behind its broker shims.
|
||||||
|
const HELPER_BINARIES: Record<string, string> = {
|
||||||
|
"/usr/bin/git": "/usr/local/libexec/rakazo/git",
|
||||||
|
"/usr/bin/gh": "/usr/local/libexec/rakazo/gh",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mount points of this process, from /proc/self/mountinfo. */
|
||||||
|
export function readMountPoints(
|
||||||
|
mountinfo = readFileSync("/proc/self/mountinfo", "utf8"),
|
||||||
|
): string[] {
|
||||||
|
return mountinfo
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.split(" ")[4])
|
||||||
|
.filter((point): point is string => Boolean(point))
|
||||||
|
.map((point) => point.replace(/\\040/g, " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inside a container only bind mounts reach the host; parent folders of a mount exist but are empty,
|
||||||
|
* so a path counts as visible only below a mount other than the root.
|
||||||
|
*/
|
||||||
|
export function visibleThroughMounts(target: string, mountPoints: string[]): boolean {
|
||||||
|
return mountPoints.some(
|
||||||
|
(point) => point !== "/" && (target === point || target.startsWith(`${point}/`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-off container that binds the workspace a containerized supervisor cannot see. */
|
||||||
|
export function helperContainerOptions(
|
||||||
|
input: BrokerCommandInput,
|
||||||
|
image: string,
|
||||||
|
user: string,
|
||||||
|
): Docker.ContainerCreateOptions {
|
||||||
|
const [binary = "", ...args] = input.argv;
|
||||||
|
return {
|
||||||
|
Image: image,
|
||||||
|
Entrypoint: [HELPER_BINARIES[binary] ?? binary],
|
||||||
|
Cmd: args,
|
||||||
|
WorkingDir: input.cwd,
|
||||||
|
User: user,
|
||||||
|
Env: Object.entries(input.env).map(([name, value]) => `${name}=${value}`),
|
||||||
|
Labels: { "rakazo.role": "github-broker-helper" },
|
||||||
|
HostConfig: { Binds: [`${input.workspacePath}:${input.workspacePath}`] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits Docker's multiplexed log stream: an 8-byte header (stream id, size) per frame. */
|
||||||
|
export function demuxDockerLogs(buffer: Buffer): { stdout: string; stderr: string } {
|
||||||
|
const out: Buffer[] = [];
|
||||||
|
const err: Buffer[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (offset + 8 <= buffer.length) {
|
||||||
|
const size = buffer.readUInt32BE(offset + 4);
|
||||||
|
const frame = buffer.subarray(offset + 8, offset + 8 + size);
|
||||||
|
(buffer[offset] === 2 ? err : out).push(frame);
|
||||||
|
offset += 8 + size;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stdout: Buffer.concat(out).toString("utf8"),
|
||||||
|
stderr: Buffer.concat(err).toString("utf8"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHelperRunner(
|
||||||
|
docker: Docker,
|
||||||
|
image: string,
|
||||||
|
user: string,
|
||||||
|
mountPoints: string[] = readMountPoints(),
|
||||||
|
): BrokerOutsideRunner {
|
||||||
|
return {
|
||||||
|
sees: (target) => visibleThroughMounts(target, mountPoints),
|
||||||
|
async run(input): Promise<BrokerCommandResult> {
|
||||||
|
let container: Docker.Container | undefined;
|
||||||
|
try {
|
||||||
|
container = await docker.createContainer(helperContainerOptions(input, image, user));
|
||||||
|
await container.start();
|
||||||
|
const { StatusCode } = (await container.wait()) as { StatusCode: number };
|
||||||
|
const logs = (await container.logs({
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
follow: false,
|
||||||
|
})) as Buffer;
|
||||||
|
return { ...demuxDockerLogs(logs), code: StatusCode };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { stdout: "", stderr: message, code: 1 };
|
||||||
|
} finally {
|
||||||
|
await container?.remove({ force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -12,6 +12,12 @@ import {
|
|||||||
hostGitInvocation,
|
hostGitInvocation,
|
||||||
hostPathFor,
|
hostPathFor,
|
||||||
} from "./github-broker.js";
|
} from "./github-broker.js";
|
||||||
|
import {
|
||||||
|
demuxDockerLogs,
|
||||||
|
helperContainerOptions,
|
||||||
|
readMountPoints,
|
||||||
|
visibleThroughMounts,
|
||||||
|
} from "./github-broker-helper.js";
|
||||||
import { hasValidBearerToken } from "./supervisor-logic.js";
|
import { hasValidBearerToken } from "./supervisor-logic.js";
|
||||||
|
|
||||||
const session: GithubBrokerSession = {
|
const session: GithubBrokerSession = {
|
||||||
@ -123,3 +129,55 @@ describe("github broker host tools", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("github broker helper container", () => {
|
||||||
|
const mounts = readMountPoints(
|
||||||
|
[
|
||||||
|
"1 0 0:1 / / rw - overlay overlay rw",
|
||||||
|
"2 1 0:2 /srv/data /srv/data rw - ext4 /dev/sda1 rw",
|
||||||
|
"3 1 0:3 / /var/run/docker.sock rw - tmpfs tmpfs rw",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("sees only paths below the supervisor's own mounts", () => {
|
||||||
|
expect(mounts).toEqual(["/", "/srv/data", "/var/run/docker.sock"]);
|
||||||
|
expect(visibleThroughMounts("/srv/data/homes/chief", mounts)).toBe(true);
|
||||||
|
// The parent of a bind mount exists in the container but is empty.
|
||||||
|
expect(visibleThroughMounts("/srv", mounts)).toBe(false);
|
||||||
|
expect(visibleThroughMounts("/home/owner/Projects/app", mounts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds the workspace and runs the computer image's real binary", () => {
|
||||||
|
const options = helperContainerOptions(
|
||||||
|
{
|
||||||
|
argv: ["/usr/bin/git", "push", "origin", "fix"],
|
||||||
|
cwd: "/home/owner/Projects/app",
|
||||||
|
env: { GITHUB_TOKEN: "ghp_test" },
|
||||||
|
workspacePath: "/home/owner/Projects/app",
|
||||||
|
},
|
||||||
|
"rakazo/computer:local",
|
||||||
|
"1000:1000",
|
||||||
|
);
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
Image: "rakazo/computer:local",
|
||||||
|
Entrypoint: ["/usr/local/libexec/rakazo/git"],
|
||||||
|
Cmd: ["push", "origin", "fix"],
|
||||||
|
WorkingDir: "/home/owner/Projects/app",
|
||||||
|
User: "1000:1000",
|
||||||
|
Env: ["GITHUB_TOKEN=ghp_test"],
|
||||||
|
HostConfig: { Binds: ["/home/owner/Projects/app:/home/owner/Projects/app"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits Docker's multiplexed output into stdout and stderr", () => {
|
||||||
|
const frame = (stream: number, text: string) => {
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header[0] = stream;
|
||||||
|
header.writeUInt32BE(Buffer.byteLength(text), 4);
|
||||||
|
return Buffer.concat([header, Buffer.from(text)]);
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
demuxDockerLogs(Buffer.concat([frame(1, "out"), frame(2, "err"), frame(1, "!")])),
|
||||||
|
).toEqual({ stdout: "out!", stderr: "err" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -94,6 +94,23 @@ export function gitProcessIdentity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BrokerCommandResult = { stdout: string; stderr: string; code: number };
|
||||||
|
|
||||||
|
export type BrokerCommandInput = {
|
||||||
|
argv: string[];
|
||||||
|
cwd: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
workspacePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runs git or gh for a workspace this process cannot see, e.g. a project folder outside a container's mounts. */
|
||||||
|
export type BrokerOutsideRunner = {
|
||||||
|
sees(target: string): boolean;
|
||||||
|
run(input: BrokerCommandInput): Promise<BrokerCommandResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let runOutside: BrokerOutsideRunner | undefined;
|
||||||
|
|
||||||
const sessions = new Map<string, GithubBrokerSession>();
|
const sessions = new Map<string, GithubBrokerSession>();
|
||||||
|
|
||||||
export function githubBrokerSessionsForTests() {
|
export function githubBrokerSessionsForTests() {
|
||||||
@ -123,18 +140,21 @@ async function runHostGit(
|
|||||||
session: GithubBrokerSession,
|
session: GithubBrokerSession,
|
||||||
argv: string[],
|
argv: string[],
|
||||||
cwd = session.workspacePath,
|
cwd = session.workspacePath,
|
||||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
): Promise<BrokerCommandResult> {
|
||||||
|
const env = {
|
||||||
|
HOME: session.workspacePath,
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
GH_TOKEN: session.githubToken,
|
||||||
|
GITHUB_TOKEN: session.githubToken,
|
||||||
|
};
|
||||||
|
if (runOutside && !runOutside.sees(cwd)) {
|
||||||
|
return runOutside.run({ argv, cwd, env, workspacePath: session.workspacePath });
|
||||||
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(argv[0]!, argv.slice(1), {
|
const child = spawn(argv[0]!, argv.slice(1), {
|
||||||
cwd,
|
cwd,
|
||||||
...gitProcessIdentity(session.workspacePath),
|
...gitProcessIdentity(session.workspacePath),
|
||||||
env: {
|
env: { ...process.env, ...env },
|
||||||
...process.env,
|
|
||||||
HOME: session.workspacePath,
|
|
||||||
GIT_TERMINAL_PROMPT: "0",
|
|
||||||
GH_TOKEN: session.githubToken,
|
|
||||||
GITHUB_TOKEN: session.githubToken,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
@ -177,7 +197,12 @@ async function resolveDefaultBranch(session: GithubBrokerSession): Promise<strin
|
|||||||
return match?.[1]?.trim() || null;
|
return match?.[1]?.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) {
|
export function mountGithubBrokerRoutes(
|
||||||
|
app: Hono,
|
||||||
|
supervisorToken: string,
|
||||||
|
outside?: BrokerOutsideRunner,
|
||||||
|
) {
|
||||||
|
if (outside) runOutside = outside;
|
||||||
app.use("/internal/github-broker/*", async (c, next) => {
|
app.use("/internal/github-broker/*", async (c, next) => {
|
||||||
const pathname = c.req.path;
|
const pathname = c.req.path;
|
||||||
if (pathname.startsWith("/internal/github-broker/sessions")) {
|
if (pathname.startsWith("/internal/github-broker/sessions")) {
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import {
|
|||||||
xdotoolCommand,
|
xdotoolCommand,
|
||||||
} from "./computer-spec.js";
|
} from "./computer-spec.js";
|
||||||
import { mountGithubBrokerRoutes } from "./github-broker.js";
|
import { mountGithubBrokerRoutes } from "./github-broker.js";
|
||||||
|
import { createHelperRunner } from "./github-broker-helper.js";
|
||||||
import { assertComputerHomeWritable } from "./home-ownership.js";
|
import { assertComputerHomeWritable } from "./home-ownership.js";
|
||||||
import {
|
import {
|
||||||
assertRequestIdentity,
|
assertRequestIdentity,
|
||||||
@ -135,13 +136,18 @@ const app = new Hono();
|
|||||||
|
|
||||||
export { app as supervisorApp };
|
export { app as supervisorApp };
|
||||||
|
|
||||||
mountGithubBrokerRoutes(app, supervisorToken);
|
// A containerized supervisor only sees its own mounts; git for other project folders runs in a
|
||||||
|
// one-off computer-image container that binds the folder.
|
||||||
|
const brokerOutsideRunner = existsSync("/.dockerenv")
|
||||||
|
? createHelperRunner(docker, COMPUTER_IMAGE, COMPUTER_USER)
|
||||||
|
: undefined;
|
||||||
|
mountGithubBrokerRoutes(app, supervisorToken, brokerOutsideRunner);
|
||||||
|
|
||||||
// Computers reach the broker through host.docker.internal. A loopback-bound supervisor on Linux is
|
// Computers reach the broker through host.docker.internal. A loopback-bound supervisor on Linux is
|
||||||
// invisible there, so it also serves the broker alone on the default bridge gateway, which only
|
// invisible there, so it also serves the broker alone on the default bridge gateway, which only
|
||||||
// containers on this host can reach.
|
// containers on this host can reach.
|
||||||
const brokerApp = new Hono();
|
const brokerApp = new Hono();
|
||||||
mountGithubBrokerRoutes(brokerApp, supervisorToken);
|
mountGithubBrokerRoutes(brokerApp, supervisorToken, brokerOutsideRunner);
|
||||||
|
|
||||||
export function needsBridgeBrokerListener(
|
export function needsBridgeBrokerListener(
|
||||||
hostname: string,
|
hostname: string,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user