loopat 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -38,7 +38,8 @@
38
38
  "postinstall": "node scripts/install-sandbox-claude.mjs",
39
39
  "test:e2e": "playwright test",
40
40
  "test:e2e:ui": "playwright test --ui",
41
- "dogfood": "playwright test --config dogfood/playwright.config.ts"
41
+ "dogfood": "playwright test --config dogfood/playwright.config.ts",
42
+ "dogfood:first-run": "playwright test --config dogfood/first-run/playwright.config.ts"
42
43
  },
43
44
  "dependencies": {
44
45
  "@anthropic-ai/claude-agent-sdk": "^0.3.150",
@@ -20,6 +20,7 @@ import {
20
20
  listLoops,
21
21
  loopExists,
22
22
  patchLoopMeta,
23
+ userOnboarding,
23
24
  type LoopMeta,
24
25
  } from "./loops"
25
26
  import { getSession, type LoopSessionMessageListener } from "./session"
@@ -358,6 +359,13 @@ export function buildApiV1(): Hono<{ Variables: Variables }> {
358
359
 
359
360
  v1.post("/loops", requireApiAuth, async (c) => {
360
361
  const userId = c.get("userId") as string
362
+ // Onboarding gate: when the active provider requires onboarding, block loop
363
+ // creation until it's complete — same gate as the legacy POST /api/loops.
364
+ // (Without this, the UI's v1 create path bypasses the onboarding gate.)
365
+ const ob = await userOnboarding(userId)
366
+ if (ob.gated && !ob.done) {
367
+ return apiError(c, 403, "permission_error", "onboarding_incomplete", "onboarding incomplete")
368
+ }
361
369
  const body = await c.req.json().catch(() => ({}))
362
370
  const title = typeof body.title === "string" ? body.title.trim() : ""
363
371
  if (title.length > 200) return apiError(c, 400, "invalid_request_error", "title_too_long", "title exceeds 200 chars")
@@ -312,7 +312,13 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
312
312
  console.log(`[loopat] cloned ${url} → ${dir}`)
313
313
  return
314
314
  } catch (e: any) {
315
- console.warn(`[loopat] clone failed (${url}): ${e?.stderr ?? e?.message ?? e}falling back to local origin`)
315
+ // The WORKSPACE-DEFAULT context clone uses the host's bare ssh it has
316
+ // no per-user vault key, so a private ssh:// URL here is EXPECTED to fail
317
+ // `Permission denied (publickey)`. This is a bootstrap display mirror only
318
+ // (loops use the per-user path with the vault key, see ensureUserContext),
319
+ // so we fall back to a local origin. Log at info, and DON'T echo the raw
320
+ // "Permission denied (publickey)" — it gets mistaken for a loop-auth bug.
321
+ console.log(`[loopat] workspace-default ${name} not cloned over ssh (no host credential for ${url}) — using local origin (loops use the per-user vault key, unaffected)`)
316
322
  }
317
323
  }
318
324
  // Local backend: loopat-hosted bare origin.
@@ -35,8 +35,8 @@
35
35
  */
36
36
  import { execFile, spawn } from "node:child_process"
37
37
  import { createHash } from "node:crypto"
38
- import { existsSync } from "node:fs"
39
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
38
+ import { existsSync, readdirSync, statSync } from "node:fs"
39
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
40
40
  import { homedir, tmpdir } from "node:os"
41
41
  import { join, dirname } from "node:path"
42
42
  import { promisify } from "node:util"
@@ -57,6 +57,7 @@ import {
57
57
  loopHomeUpper,
58
58
  workspaceHomeSkelDir,
59
59
  loopDir,
60
+ personalVaultMountsHomeDir,
60
61
  } from "./paths"
61
62
  import { loadConfig } from "./config"
62
63
  import { DEFAULT_VAULT, listVaultHomeMounts } from "./vaults"
@@ -1147,6 +1148,45 @@ export async function ensurePortProxyContainer(): Promise<void> {
1147
1148
  const SERVE_HOST = process.env.LOOPAT_SERVE_HOST ?? "127.0.0.1"
1148
1149
  const SERVE_PORT = Number(process.env.LOOPAT_SERVE_PORT ?? 7788)
1149
1150
 
1151
+ /**
1152
+ * Lock down the vault `.ssh` perms RIGHT BEFORE the container that bind-mounts
1153
+ * it starts. The vault's `.ssh` is git-crypt-decrypted / git-checked-out, so its
1154
+ * private key lands at the umask default (0664) and the dir at 0775 — and git
1155
+ * can't carry 0600 (it only tracks the exec bit). loops.ts chmods the key 0600
1156
+ * "at point of use" host-side, but that only runs when a HOST git op goes through
1157
+ * sshCommandForUser; a container that's created/recreated on a path that didn't
1158
+ * (server restart → first attach, config/image-drift recreate) would bind-mount
1159
+ * a stale-perms key. ssh then ignores the key (or rejects it under StrictModes)
1160
+ * and the FIRST sandbox ssh fails `Permission denied (publickey)`; a later op
1161
+ * that re-chmods host-side makes the retry succeed — exactly the intermittent
1162
+ * we saw. Do it here, in the one chokepoint every container start passes
1163
+ * through, so the perms are correct the instant the bind goes live. Cheap +
1164
+ * idempotent; best-effort (a missing vault is fine — the mount just won't exist).
1165
+ */
1166
+ export async function ensureSandboxSshPerms(user: string, vault: string): Promise<void> {
1167
+ const sshDir = join(personalVaultMountsHomeDir(user, vault), ".ssh")
1168
+ if (!existsSync(sshDir)) return
1169
+ try {
1170
+ await chmod(sshDir, 0o700).catch(() => {})
1171
+ for (const name of readdirSync(sshDir)) {
1172
+ const p = join(sshDir, name)
1173
+ try {
1174
+ if (!statSync(p).isFile()) continue
1175
+ } catch { continue }
1176
+ // Private keys MUST be 0600 (id_*, *_rsa/_ed25519/_ecdsa with no .pub) and
1177
+ // `config` MUST NOT be group/world-writable or ssh silently ignores it.
1178
+ // .pub / known_hosts are fine readable; clamp them to 0644 to be safe.
1179
+ const isPub = name.endsWith(".pub")
1180
+ const isPrivKey = !isPub && /^id_|_rsa$|_ed25519$|_ecdsa$|_dsa$|^identity$/.test(name)
1181
+ if (name === "config" || isPrivKey) {
1182
+ await chmod(p, 0o600).catch(() => {})
1183
+ } else {
1184
+ await chmod(p, 0o644).catch(() => {})
1185
+ }
1186
+ }
1187
+ } catch {}
1188
+ }
1189
+
1150
1190
  /**
1151
1191
  * Idempotent: bring the container to "running with current config".
1152
1192
  * - missing → podman create + start
@@ -1157,6 +1197,10 @@ const SERVE_PORT = Number(process.env.LOOPAT_SERVE_PORT ?? 7788)
1157
1197
  */
1158
1198
  export async function ensureContainer(opts: ContainerOptions, progress?: { onProgress?: (msg: string) => void }): Promise<void> {
1159
1199
  await ensureLoopatNetwork()
1200
+ // Deterministically fix the vault ssh-key perms BEFORE the container that
1201
+ // bind-mounts them starts, so the first in-sandbox ssh authenticates reliably
1202
+ // regardless of how/when the key was git-checked-out (see ensureSandboxSshPerms).
1203
+ await ensureSandboxSshPerms(opts.createdBy, opts.vaultName?.trim() || DEFAULT_VAULT)
1160
1204
  // Resolve the image first — for loops with a composed mise.toml this
1161
1205
  // builds (or reuses) a per-loop child image with toolchains baked in.
1162
1206
  // For loops without mise.toml, this returns the base SANDBOX_IMAGE.