Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ Self-hosted AI workspace built around context management. Monorepo with two work
## Tech stack

- **Runtime**: Bun (server + build tooling)
- **Server**: Hono, TypeScript, Claude Agent SDK, podman (sandbox containers)
- **Server**: Hono, TypeScript, Claude Agent SDK, Codex CLI runtime, podman (sandbox containers)
- **Web**: React 19, Vite 8, Tailwind CSS v4, Zustand, assistant-ui, xterm.js, CodeMirror, Milkdown
- **Infra**: Docker (oven/bun base), rootless podman inside container
- **Tests**: Playwright (e2e + dogfood)
- **Rust**: Two small binaries in `server/src/serve-rs` and `server/src/port-proxy-rs`, built as part of `web build`

## Agent runtimes

- Providers declare `runtime: "claude" | "codex"`; legacy provider configs default to Claude.
- Claude runs through the Agent SDK inside podman. Codex runs through the host CLI and must not initialize Claude settings, plugins, MCP servers, containers, or the Anthropic egress gateway.
- Keep Codex command construction, environment mapping, and JSONL parsing in `server/src/codex-cli.ts`. Session code owns lifecycle and UI event translation only.
- Add focused adapter tests when Codex CLI arguments or event parsing changes. Preserve the existing Claude path unless a migration is explicitly required.

## Development

```bash
Expand Down
150 changes: 150 additions & 0 deletions docs/gitlab-personal-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# GitLab Personal Storage

Loopat can use a GitLab-compatible repository for encrypted personal storage.
GitHub remains the default; administrators can select GitLab through workspace
configuration or environment variables.

```jsonc
{
"gitHost": {
"provider": "gitlab",
"baseUrl": "https://gitlab.example.com",
"defaultRepo": "team/loopat-personal"
}
}
```

Equivalent environment variables are:

```sh
LOOPAT_GIT_HOST_PROVIDER=gitlab
LOOPAT_GIT_HOST_BASE_URL=https://gitlab.example.com
LOOPAT_GIT_HOST_DEFAULT_REPO=team/loopat-personal
```

## Design

The implementation uses the existing `GitHostProvider` abstraction rather than
adding GitLab conditionals to the personal-storage workflow. Provider resolution
uses the following precedence:

1. An extension under `LOOPAT_HOME/extensions/providers/`.
2. A provider explicitly supplied by the request.
3. `LOOPAT_GIT_HOST_PROVIDER` or `LOOPAT_GIT_PROVIDER`.
4. Workspace `config.json` field `gitHost.provider`.
5. Built-in `github`.

`resolveGitHostSettings()` applies the same request, environment, workspace, and
provider-default precedence to `baseUrl` and `defaultRepo`.

For GitLab, `baseUrl` is the human-facing site root. The provider derives the
REST endpoint as `${baseUrl}/api/v4`; a configured URL that already ends in
`/api/v4` is normalized back to the site root when building web and clone URLs.

## Authentication

### Token flow

The standard flow uses a personal or project access token with these scopes:

- `api`
- `read_repository`
- `write_repository`

API requests try the standard `PRIVATE-TOKEN` header first, followed by bearer
authentication and the `private_token` query parameter for compatible
self-hosted deployments. Redirects, HTML responses, and authentication errors
advance to the next mode.

Git clone and push use the provider's `https-token` mode:

```text
https://<resolved-username-or-oauth2>:<token>@gitlab.example.com/<namespace>/<project>.git
```

The provider reads standard GitLab user responses and common wrapped enterprise
responses. If the profile endpoint does not expose a namespace username, a
successful project-list request can still validate the token. The user must then
enter a full `namespace/project` path; Loopat uses `oauth2` only as the non-empty
HTTPS Basic Auth username.

### Administrator-pinned SSH flow

Some self-hosted installations place the API behind SSO while leaving SSH git
access available. When an administrator configures a full `namespace/project`
as `defaultRepo`, Loopat resolves it to an SSH URL and skips token onboarding:

```text
git@gitlab.example.com:team/loopat-personal.git
```

Direct SSH uses the operating-system account that runs Loopat. Request-body
overrides cannot select another host-SSH repository; the repository must come
from administrator-controlled workspace or environment configuration. Verify
that the process account has write access before enabling this mode.

The imported repository records `loopat.personalSshAuth=host` in local Git
configuration. Initial push, fetch, pull, delete synchronization, and later
pushes all reuse the same host identity. Host key checking uses
`StrictHostKeyChecking=accept-new` when supported and falls back to `no` for
older OpenSSH versions; administrators using the fallback should pre-seed and
monitor `known_hosts`.

## Repository identity

Personal-repository commits select an author in this order:

1. Provider identity, when both name and email are available.
2. `LOOPAT_GIT_AUTHOR_NAME` and `LOOPAT_GIT_AUTHOR_EMAIL`.
3. Existing repository or process-account Git configuration.
4. Loopat's local fallback identity.

Installations with commit-email push rules should configure an accepted email
through the environment or the process account's Git configuration.

## Runtime flow

1. The UI calls `GET /api/personal/status`.
2. The server resolves the active git host, base URL, and default repository.
3. For token mode, the UI authenticates and lists repositories through the
provider.
4. For direct SSH mode, the UI shows the administrator-pinned repository for
confirmation and skips token entry.
5. The backward-compatible `POST /api/personal/github` route delegates setup to
the active provider.
6. `setupPersonalViaProvider()` clones or initializes the repository, imports
the personal data, and configures git-crypt when needed.

## Code map

- `server/src/gitlab.ts` implements the GitLab API adapter, response parsing,
repository creation, project listing, and direct SSH resolution.
- `server/src/providers.ts` registers built-in providers and resolves active git
host settings.
- `server/src/index.ts` routes personal status, repository listing, and setup
through the active provider.
- `server/src/loops.ts` preserves full namespace paths and applies the selected
Git authentication mode to all synchronization operations.
- `web/src/components/dialog/PersonalRepoPanel.tsx` renders token and direct SSH
onboarding from the provider-neutral status response.
- `server/src/git-author.ts` selects a push-rule-compatible commit identity.

## Validation

```sh
bun test server/test/gitlab.test.ts server/test/git-author.test.ts
bunx tsc -p server/tsconfig.json --noEmit
bun run build
```

End-to-end validation additionally requires either a usable GitLab token or SSH
write access from the Loopat process account.

## Limitations

- The public setup route is still named `/api/personal/github` for backward
compatibility. A provider-neutral alias can be added separately.
- Group and subgroup project creation depends on
`GET /api/v4/namespaces/:path` and the token's namespace permissions.
- Repository listing currently reads the first 100 membership projects.
- Direct SSH currently assumes the GitLab SSH service uses its standard port.
28 changes: 28 additions & 0 deletions docs/setup-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ empty fields after first run. Fill it in:
| `repos[]` | each entry → cloned to `context/repos/<name>/`. Loops spawn against these. |
| `mounts[]` | **operator-level** mounts; `src` is any host path. Shared by every loop on this workspace. See §6. |

Personal storage git host can also be selected here:

```jsonc
{
"gitHost": {
"provider": "gitlab",
"baseUrl": "https://gitlab.example.com",
"defaultRepo": "team/loopat-personal"
}
}
```

`provider` defaults to `github`; standard GitLab hosts use the GitLab-compatible
`/api/v4` API and HTTPS token git auth. A full, administrator-configured
`namespace/repo` value enables direct SSH onboarding instead. This supports
self-hosted installations whose API is protected by SSO: Loopat skips the API
and uses the operating-system account's existing SSH identity for clone, pull,
and push. Verify that account has write access before exposing the setup UI. The
same values can be supplied with
`LOOPAT_GIT_HOST_PROVIDER`, `LOOPAT_GIT_HOST_BASE_URL`, and
`LOOPAT_GIT_HOST_DEFAULT_REPO`.

Personal-repo commits use the provider identity when available, then
`LOOPAT_GIT_AUTHOR_NAME` / `LOOPAT_GIT_AUTHOR_EMAIL`, then the operating-system
account's Git configuration. Hosts with commit-email push rules should set a
valid email globally (`git config --global user.email ...`) or
through `LOOPAT_GIT_AUTHOR_EMAIL`.

Provider config (`apiKey`, `model`, `baseUrl`) is **not** here — that
lives per-user under `personal/<user>/.loopat/config.json`. Admins
don't pre-fill API keys for the team.
Expand Down
17 changes: 17 additions & 0 deletions docs/troubleshoot.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

If chat doesn't work or the UI shows red errors, walk this list top-to-bottom. Most issues land in §1 or §2.

## Codex exits with code 1

Loopat records Codex CLI diagnostics in the loop's `stderr.log`. Codex API
failures arrive as JSONL events on stdout; Loopat extracts `error` and
`turn.failed` events and shows their message in the chat.

If the selected model requires a newer Codex CLI, upgrade the CLI or point
Loopat at a newer binary before restarting:

```sh
LOOPAT_CODEX_BIN=/path/to/codex bun run dev
```

On macOS, ChatGPT may include a newer CLI at
`/Applications/ChatGPT.app/Contents/Resources/codex`. Confirm it with
`codex --version` before configuring the path.

## 0. The bootstrap banner is the first signal

Whatever's wrong, look at the banner `bun run dev` prints first:
Expand Down
2 changes: 1 addition & 1 deletion server/src/auto-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ async function resolveProvidersForLoop(meta: { createdBy: string; config?: { def
if (seen.has(name)) continue
seen.add(name)
const p = pCfg.providers[name] ?? wCfg.providers?.[name]
if (p && p.apiKey) result.push(p)
if (p && p.apiKey && p.runtime !== "codex") result.push(p)
}
return result
}
Expand Down
35 changes: 30 additions & 5 deletions server/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import {
workspaceTeamClaudeMdPath,
} from "./paths"
import { listUsers } from "./auth"
import { codexBinary } from "./codex-cli"

type Check = { ok: boolean; label: string; hint?: string }
type Check = { ok: boolean; label: string; hint?: string; required?: boolean }

/** The host to print in the "open …" url. HOST=0.0.0.0/:: means "all
* interfaces" — localhost works locally but isn't reachable from other
Expand Down Expand Up @@ -84,6 +85,20 @@ function checkClaudeBinary(): Check {
}
}

function checkCodexCli(): Check {
const codexBin = codexBinary()
try {
const out = execFileSync(codexBin, ["--version"], { stdio: "pipe" }).toString().trim()
return { ok: true, label: `codex cli: ${out || "available"}` }
} catch {
return {
ok: false,
label: "codex cli",
hint: "install Codex CLI or run `codex login` on the host if you want the Codex runtime",
}
}
}

function checkGitCrypt(): Check {
try {
const out = execFileSync("git-crypt", ["--version"], { stdio: "pipe" }).toString().trim()
Expand Down Expand Up @@ -126,6 +141,15 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) {
// notes is declared inside the knowledge repo's .loopat/config.json. The repo
// roster is per-user (personal config), so the workspace banner can't list it.
const kcfg = await loadKnowledgeConfig()
const podman = checkPodman()
const claude = checkClaudeBinary()
const codex = checkCodexCli()
const hasUsableAgentRuntime = codex.ok || (podman.ok && claude.ok)
if (hasUsableAgentRuntime) {
podman.required = false
claude.required = false
codex.required = false
}
const checks: Check[] = [
{ ok: true, label: `workspace: ${workspaceDir()}` },
{ ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` },
Expand All @@ -134,8 +158,9 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) {
{ ok: true, label: `repos: (per-user, in personal config)` },
await checkUsers(),
{ ok: existsSync(configPath()), label: `config: ${configPath()}` },
checkPodman(),
checkClaudeBinary(),
podman,
claude,
codex,
checkGitCrypt(),
]

Expand All @@ -154,9 +179,9 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) {
if (!c.ok && c.hint) console.log(` ${yellow("→ " + c.hint)}`)
}
console.log(bar)
const blockers = checks.filter((c) => !c.ok)
const blockers = checks.filter((c) => !c.ok && c.required !== false)
if (blockers.length > 0) {
console.log(` ${yellow(`${blockers.length} thing(s) to fix`)} before chat will work — see hints above.\n`)
console.log(` ${yellow(`${blockers.length} thing(s) to fix`)} before all required startup checks pass — see hints above.\n`)
return false
}
// NB: the "ready. open …" line is intentionally NOT printed here. The banner
Expand Down
Loading