Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ jobs:
config-file: ".markdown-link-check.ci.json"
use-quiet-mode: "yes"
folder-path: "."
check-modified-files-only: ${{ github.event_name == 'pull_request' && 'yes' || 'no' }}
base-branch: "main"

# Validation job (pattern-specific)
validate:
Expand Down
4 changes: 4 additions & 0 deletions .markdown-link-check.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
{
"_comment": "nvd.nist.gov (NIST National Vulnerability Database) is bot-hostile to the checker's unauthenticated HEAD probe (returns 0) but is a stable live gov site — referenced from the dependency-analysis skill.",
"pattern": "^https://nvd\\.nist\\.gov"
},
{
"_comment": "playbook.cio.gov is a stable federal reference, but CI runners cannot reliably connect to it (status 0 from markdown-link-check).",
"pattern": "^https://playbook\\.cio\\.gov"
}
],
"replacementPatterns": [],
Expand Down
12 changes: 7 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@ validate: ## Run all validators (frontmatter + sensitive terms)
validate-kits: ## Validate neutral acq-kits specs against the hybrid/v1 schema (strict: warnings fail)
python integrations/isolation/acq-kits/validate-kits.py --strict

test-kits: ## Run the acq-kits node test suites (usai-provider generator + merge) + providers/usai emitters
test-kits: ## Run the acq-kits node test suites (usai-provider + paseo) + providers/usai emitters
@if command -v node >/dev/null 2>&1; then \
echo "==> usai-provider kit tests"; \
cd integrations/isolation/acq-kits/usai-provider && node --test 'tests/**/*.test.mjs'; \
echo "==> providers/usai catalog + emitter tests"; \
cd "$(CURDIR)/integrations/providers/usai" && node --test 'tests/**/*.test.mjs'; \
echo "==> usai-provider kit tests" && \
(cd integrations/isolation/acq-kits/usai-provider && node --test 'tests/**/*.test.mjs') && \
echo "==> paseo kit tests" && \
(cd "$(CURDIR)/integrations/isolation/acq-kits/paseo" && node --test 'tests/**/*.test.mjs') && \
echo "==> providers/usai catalog + emitter tests" && \
(cd "$(CURDIR)/integrations/providers/usai" && node --test 'tests/**/*.test.mjs'); \
else \
echo "⚠️ node not installed — skipping acq-kits node tests"; \
fi
Expand Down
16 changes: 11 additions & 5 deletions integrations/isolation/acq-kits/paseo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,19 @@ acq ports <other-sandbox> --publish 6868:6767 # another → host 6868

## Projects are pre-populated from your mounts

Every **read-write** host directory you mount into the sandbox is registered with
the Paseo daemon at startup, so the web UI already lists your repos when you
connect — no manual **Add project** per directory. This runs on every start
(idempotent) and covers all mounts, not just the primary one the `acq run`
entrypoint opens.
Every **read-write** host directory you mount into the sandbox is inspected at
startup, so the web UI already lists your repos when you connect — no manual
**Add project** per directory. This runs on every start (idempotent) and covers
all mounts, not just the primary one the `acq run` entrypoint opens.

- **Read-only mounts are skipped.**
- A mount that is itself a Git repo is registered as that project.
- A plain parent-directory mount with direct child Git repos registers those
child repos instead of the parent. This scan is intentionally shallow (direct
children only), follows direct-child symlinks that resolve to directories, and
`.git` may be either a directory or a file.
- A mount with no direct child Git repos falls back to registering the mount
itself, preserving support for non-git working directories.
- The backend runtime dir (`/.msb`) and system mounts (`/etc`, `/run`, …) are
excluded.
- Discovery reads `/proc/mounts` and keys only on portable mount properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ Two facts shape the solution:
## Decision

Ship `paseo-register-mounts.mjs`, run from the startup supervisor after the
daemon answers `/api/health`, which registers each qualifying host directory with
the daemon.
daemon answers `/api/health`, which inspects each qualifying host directory and
registers the resulting project directories with the daemon.

### How it registers a project

Expand Down Expand Up @@ -67,6 +67,26 @@ This keys entirely on portable mount properties, so it captures the three repos
on both sbx and msb and needs no per-backend tokens. A future backend that
bind-mounts host dirs as read-write `virtiofs` is covered automatically.

### How qualifying mounts become projects

After mount discovery, each qualifying mount is expanded to the project
directories to register:

1. If the mount itself has a `.git` entry, register the mount itself. This
preserves the existing behavior for the normal case where the mount is one
repository, and avoids unexpectedly registering submodules or nested repos.
2. Otherwise, inspect only the mount's direct child directories. If any child has
a `.git` entry, register each such child as its own project and do not register
the parent directory.
3. If no direct child Git repositories are found, register the mount itself. This
preserves support for intentionally-mounted non-git working directories.

The `.git` entry may be either a directory or a file, covering normal clones,
worktrees, and submodules. The child scan is intentionally shallow, not
recursive. It follows direct-child symlinks that resolve to directories, which
keeps symlinked worktrees/submodules usable under an operator-selected mount
without traversing deeper than one child level.

### Timing and cadence

Runs on **every** sandbox start, in the background, after a bounded wait for
Expand Down Expand Up @@ -99,11 +119,18 @@ means the UI shows fewer projects until the next start.
- **Requiring a project marker (`.git`, `package.json`, …) in the dir.** Rejected
as unnecessary: it would skip intentionally-mounted non-standard working dirs,
and Paseo already records non-git dirs cleanly as `kind: "non_git"`.
- **Recursive scanning under parent mounts.** Rejected as surprising and
potentially expensive: it could register vendored repos, caches, test fixtures,
or deeply nested submodules the user did not intend to expose as top-level
Paseo projects. Direct children cover the common "one parent directory with
many sibling repos" workflow.

## Consequences

- Every read-write host project mount is listed in the Paseo UI after any start
(detached `acq create` or interactive `acq run`), with no manual "Add project".
(detached `acq create` or interactive `acq run`), unless it is a plain parent
directory containing direct child Git repos. In that case, each direct child
repo is listed instead, with no manual "Add project".
- Read-only mounts and the backend runtime dir are excluded.
- The behavior is orthogonal to the worktrees-root pin (which still targets only
the primary/first mount via the shim); this helper touches projects only, never
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// paseo-register-mounts.mjs — pre-populate Paseo PROJECTS from the host
// directories mounted into this sandbox, so the web UI already lists them the
// moment you connect (no manual "Add project" per repo).
// directories mounted into this sandbox, so the web UI already lists mounted
// repos the moment you connect (no manual "Add project" per repo).
//
// WHY THIS EXISTS: acq bind-mounts one or more host project directories into the
// guest. Paseo, however, only learns about a project when something opens it
Expand All @@ -16,9 +16,13 @@
// * works for non-git dirs (registered as kind "non_git"),
// * returns { project: null, errorCode: "directory_not_found" } for a bad path
// instead of throwing.
// (Alternatives were rejected: `workspace create` mints a NEW workspace record
// every call — workspace spam across restarts — and `terminal create` leaves a
// stray terminal behind. See docs/decisions/prepopulate-projects-from-mounts.md.)
// Before registering, a qualifying mount is expanded: if the mount is itself a
// Git repo, register it; otherwise register direct child dirs that have a `.git`
// entry; if none exist, fall back to the mount itself. The child scan is shallow
// by design. (Alternatives were rejected: `workspace create` mints a NEW
// workspace record every call — workspace spam across restarts — and `terminal
// create` leaves a stray terminal behind. See
// docs/decisions/prepopulate-projects-from-mounts.md.)
//
// We import the CLI's OWN connector (dist/utils/client.js -> connectToDaemon) so
// we reuse its socket/localhost resolution and need no host/port here. The CLI
Expand Down Expand Up @@ -49,9 +53,15 @@
// connection is always closed, and the process ALWAYS exits 0 — even if the
// daemon is unreachable or some adds fail. Errors are logged for diagnosis.

import { readFileSync, realpathSync, statSync } from "node:fs";
import {
existsSync,
readdirSync,
readFileSync,
realpathSync,
statSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, basename, resolve } from "node:path";
import { basename, dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

// Target-path prefixes we never treat as a host project (system / pseudo mounts).
Expand Down Expand Up @@ -100,9 +110,55 @@ function unescapeMountField(field) {
);
}

export function hasGitEntry(dir) {
return existsSync(join(dir, ".git"));
}

export function expandMountToProjectDirs(mount) {
// If the mount itself is a repo, preserve the existing one-mount-one-project
// behavior rather than expanding nested repos or submodules beneath it.
if (hasGitEntry(mount)) return [mount];

let entries;
try {
entries = readdirSync(mount, { withFileTypes: true });
} catch {
return [mount];
}

const childRepos = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const child = join(mount, entry.name);
let isDir = entry.isDirectory();
if (!isDir && entry.isSymbolicLink()) {
try {
isDir = statSync(child).isDirectory();
} catch {
isDir = false;
}
}
if (isDir && hasGitEntry(child)) childRepos.push(child);
}

return childRepos.length > 0 ? childRepos : [mount];
}

export function projectDirsFromMounts(mounts) {
const seen = new Set();
const projects = [];
for (const mount of mounts) {
for (const project of expandMountToProjectDirs(mount)) {
if (seen.has(project)) continue;
seen.add(project);
projects.push(project);
}
}
return projects;
}

// Parse /proc/mounts and return the set of host-project target directories,
// applying the backend-agnostic rule documented in the header.
function discoverProjectMounts() {
export function discoverProjectMounts() {
let raw;
try {
raw = readFileSync("/proc/mounts", "utf8");
Expand Down Expand Up @@ -153,7 +209,11 @@ async function main() {
log("no read-write host project mounts found; nothing to register");
return;
}
log(`found ${mounts.length} host project mount(s): ${mounts.join(", ")}`);
log(`found ${mounts.length} qualifying host mount(s): ${mounts.join(", ")}`);
const projects = projectDirsFromMounts(mounts);
log(
`registering ${projects.length} project director${projects.length === 1 ? "y" : "ies"}: ${projects.join(", ")}`,
);

let connectToDaemon;
try {
Expand All @@ -172,7 +232,7 @@ async function main() {
}

try {
for (const dir of mounts) {
for (const dir of projects) {
try {
const res = await client.addProject(dir);
if (res && res.project && res.project.projectId) {
Expand All @@ -192,10 +252,15 @@ async function main() {
}

// Always exit 0 — never let project pre-population fail the sandbox.
main()
.catch((err) => {
log(`unexpected error: ${err && err.message ? err.message : String(err)}`);
})
.finally(() => {
process.exit(0);
});
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
main()
.catch((err) => {
log(`unexpected error: ${err && err.message ? err.message : String(err)}`);
})
.finally(() => {
process.exit(0);
});
}
Loading