diff --git a/apps/daemon/internal/cli/native_bundle.go b/apps/daemon/internal/cli/native_bundle.go index 3f2126ce4..ff5a7fad1 100644 --- a/apps/daemon/internal/cli/native_bundle.go +++ b/apps/daemon/internal/cli/native_bundle.go @@ -84,8 +84,6 @@ func validBundlePath(name string) bool { return name != "." && path.Clean(name) == name && !strings.ContainsAny(name, "\\\x00\r\n:") && filepath.IsLocal(filepath.FromSlash(name)) && name != ".oac-install.json" } -func nativeComponentRoot(root, name string) string { return filepath.Join(root, "components", name) } - func componentReceipt(root string) (nativeComponent, error) { var c nativeComponent err := readNativeJSON(filepath.Join(root, ".oac-install.json"), &c) @@ -130,35 +128,90 @@ func checkComponentFiles(ctx context.Context, directory string, c nativeComponen return nil } -// A complete component is published once. An interruption cannot turn a partial -// copy into an installation; a subsequent run can reuse an already-published one. -func installNativeComponent(ctx context.Context, source, root, name string, expected nativeComponent) error { - dest := nativeComponentRoot(root, name) +// verifyNativeComponent checks a stored component against its receipt and, when the distribution +// is at hand, against the distribution's manifest. +func verifyNativeComponent(ctx context.Context, dir, name string, expected *nativeComponent) error { + info, err := os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("shared component %s is missing from %s; run install again", name, filepath.Dir(dir)) + } + remedy := "stop the installations that use it, delete " + dir + " (" + nativeDeleteCommand(dir) + ") and run install again" + if err != nil || !info.IsDir() || !ownedByCurrentUser(info) { + return fmt.Errorf("shared component %s is not a directory of this account; %s", name, remedy) + } + c, err := componentReceipt(dir) + if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 { + return fmt.Errorf("shared component %s is missing files or modified; %s", name, remedy) + } + if expected != nil && !reflect.DeepEqual(c, *expected) { + return fmt.Errorf("shared component %s differs from this distribution's bundle.json; %s", name, remedy) + } + if err = checkComponentFiles(ctx, dir, c); errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + return fmt.Errorf("shared component %s is missing files, modified or unreadable; %s", name, remedy) + } else if err != nil { + return fmt.Errorf("install: cannot verify shared component %s: %w", name, err) + } + return nil +} + +// ensureNativeComponent makes the store hold name: a stored component is verified, a missing one is +// copied from the distribution directory or, with a Core address, downloaded. It is published +// once, complete and read-only, so an interruption leaves nothing partial in place. +func ensureNativeComponent(ctx context.Context, out io.Writer, o *nativeInstallOptions, name string, expected nativeComponent) error { + dest := filepath.Join(o.ComponentDirectory, name) if _, err := os.Lstat(dest); err == nil { - got, e := componentReceipt(dest) - if e != nil || !reflect.DeepEqual(got, expected) { - return fmt.Errorf("install: existing %s is incompatible; preserve it and use a separate installation directory", name) - } - if err := checkComponentFiles(ctx, dest, got); err != nil { - if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("install: existing %s is incomplete or modified; reinstall separately", name) - } - return fmt.Errorf("install: cannot verify existing %s: %w", name, err) + err = nativeInstallPhase(out, "Using installed "+name, func() error { return verifyNativeComponent(ctx, dest, name, &expected) }) + if err != nil { + return err } - return nil + // An interruption between the rename and the seal leaves the top directory writable. + return sealNativeComponent(dest) } else if !errors.Is(err, os.ErrNotExist) { return err } - parent := filepath.Dir(dest) - if err := os.MkdirAll(parent, 0700); err != nil { + source := filepath.Join(o.Bundle, "components", name) + _, missing := os.Lstat(source) + if missing != nil && o.OnboardURL == "" { + return fmt.Errorf("install: component %s is missing from %s; extract %s-%s-%s.tar.gz into it and retry", name, o.Bundle, runtime.GOOS, runtime.GOARCH, name) + } + stage, err := os.MkdirTemp(o.ComponentDirectory, ".install-"+name+"-") + if err != nil { return err } - tmp, err := os.MkdirTemp(parent, ".install-"+name+"-") + defer removeNativeTree(stage) + var archive string + if missing == nil { + err = nativeInstallPhase(out, "Installing "+name, func() error { return copyNativeComponent(ctx, source, stage, expected) }) + } else { + // A download prints its own progress, which a redrawn phase line would interleave with. + fmt.Fprintln(out, "Installing "+name+"...") + archive, err = downloadNativeComponent(ctx, out, o.OnboardURL, name, stage, expected) + } if err != nil { return err } - defer os.RemoveAll(tmp) - src, err := os.OpenRoot(filepath.Join(source, "components", name)) + raw, _ := json.Marshal(expected) + if err = os.WriteFile(filepath.Join(stage, ".oac-install.json"), raw, 0600); err != nil { + return err + } + if err = ctx.Err(); err != nil { + return err + } + if err = setNativeReadOnly(stage); err != nil { + return err + } + if err = os.Rename(stage, dest); err != nil { + return err + } + if archive != "" { + _ = os.Remove(archive) + } + return sealNativeComponent(dest) +} + +// copyNativeComponent copies the manifest's files from the distribution into stage, verifying each. +func copyNativeComponent(ctx context.Context, source, stage string, expected nativeComponent) error { + src, err := os.OpenRoot(source) if err != nil { return err } @@ -177,16 +230,13 @@ func installNativeComponent(ctx context.Context, source, root, name string, expe } required += uint64(info.Size()) } - if err = requireNativeSpace(parent, required); err != nil { + if err = requireNativeSpace(filepath.Dir(stage), required); err != nil { return err } for name, expectedFile := range expected.Files { if err := ctx.Err(); err != nil { return err } - if !validBundlePath(name) { - return errors.New("install: invalid component path") - } in, err := src.Open(filepath.FromSlash(name)) if err != nil { return errors.New("install: component file unavailable") @@ -196,7 +246,7 @@ func installNativeComponent(ctx context.Context, source, root, name string, expe in.Close() return errors.New("install: component must contain regular files") } - destination := filepath.Join(tmp, filepath.FromSlash(name)) + destination := filepath.Join(stage, filepath.FromSlash(name)) if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { in.Close() return err @@ -227,14 +277,7 @@ func installNativeComponent(ctx context.Context, source, root, name string, expe return errors.New("install: component checksum failed") } } - raw, _ := json.Marshal(expected) - if err = os.WriteFile(filepath.Join(tmp, ".oac-install.json"), raw, 0600); err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - return os.Rename(tmp, dest) + return nil } func selectedNativeHarnesses(value string) ([]string, error) { diff --git a/apps/daemon/internal/cli/native_download.go b/apps/daemon/internal/cli/native_download.go index 50a003ec2..e79317190 100644 --- a/apps/daemon/internal/cli/native_download.go +++ b/apps/daemon/internal/cli/native_download.go @@ -45,64 +45,36 @@ func nativeInstallerBase(onboardURL string) string { return strings.TrimSuffix(onboardURL, "installation") + "install/" + Version } -// obtainNativeComponents makes the distribution directory hold node and each selected Harness. -// Without a Core address nothing is downloaded and a missing component is an error. -func obtainNativeComponents(ctx context.Context, out io.Writer, o *nativeInstallOptions, selected []string) error { - var missing []string - for _, name := range append([]string{"node"}, selected...) { - _, inBundle := os.Lstat(nativeComponentRoot(o.Bundle, name)) - _, installed := os.Lstat(nativeComponentRoot(o.Directory, name)) - if inBundle != nil && installed != nil { - missing = append(missing, name) - } - } - if len(missing) == 0 { - return nil - } - if o.OnboardURL == "" { - return fmt.Errorf("install: component %s is missing from %s; extract %s-%s-%s.tar.gz into it and retry", missing[0], o.Bundle, runtime.GOOS, runtime.GOARCH, missing[0]) - } - return fetchNativeComponents(ctx, out, o.OnboardURL, o.Bundle, missing) -} - -// fetchNativeComponents downloads the archive of each named component and extracts it into the -// distribution directory. Verified archives stay in the Runtime home, keyed by version, so a later -// installation of the same release reuses them. This is the only owner of that directory's lock. -func fetchNativeComponents(ctx context.Context, out io.Writer, onboardURL, bundle string, names []string) error { +// nativeDownloadDirectory holds only transient files: the installer's bundle.* directories and the +// partial archive that a retry or a later run resumes. +func nativeDownloadDirectory() (string, error) { home, err := paths.Root() if err != nil { - return err + return "", err } cache := filepath.Join(home, "native-download") if err = runtimefs.EnsurePrivateDir(cache); err != nil { - return err - } - held, err := os.OpenRoot(cache) - if err != nil { - return err - } - defer held.Close() - unlock, err := runtimefs.LockDirectory(held) - if err != nil { - return errors.New("install: another native download is running; wait for it to finish and retry") + return "", err } - defer unlock() pruneNativeDownloads(cache) dir := filepath.Join(cache, Version) - if err = os.MkdirAll(dir, 0700); err != nil { - return err + return dir, os.MkdirAll(dir, 0700) +} + +// downloadNativeComponent fetches the archive of one component, verifies it against Core's +// checksum and extracts it into stage. It returns the archive, which the caller deletes once the +// component is published. +func downloadNativeComponent(ctx context.Context, out io.Writer, onboardURL, name, stage string, expected nativeComponent) (string, error) { + dir, err := nativeDownloadDirectory() + if err != nil { + return "", err } - for _, name := range names { - file := runtime.GOOS + "-" + runtime.GOARCH + "-" + name - archive, err := fetchNativeArchive(ctx, out, nativeInstallerBase(onboardURL)+"/"+file, dir, file) - if err == nil { - err = extractNativeComponent(ctx, out, archive, bundle, name) - } - if err != nil { - return err - } + file := runtime.GOOS + "-" + runtime.GOARCH + "-" + name + archive, err := fetchNativeArchive(ctx, out, nativeInstallerBase(onboardURL)+"/"+file, dir, file) + if err != nil { + return "", err } - return nil + return archive, extractNativeComponent(ctx, out, archive, stage, name, expected) } // pruneNativeDownloads keeps the current version and recent distribution directories of running @@ -118,22 +90,13 @@ func pruneNativeDownloads(cache string) { } } -// fetchNativeArchive returns the verified cached archive, downloading or resuming it when needed. +// fetchNativeArchive returns the verified archive, downloading or resuming it as needed. func fetchNativeArchive(ctx context.Context, out io.Writer, url, dir, file string) (string, error) { want, err := fetchNativeChecksum(ctx, out, url+".sha256") if err != nil { return "", err } - archive := filepath.Join(dir, file+".tar.gz") - if got, err := nativeFileDigest(ctx, archive); err == nil { - if got == want { - fmt.Fprintf(out, "Using the downloaded %s archive.\n", file) - return archive, nil - } - fmt.Fprintf(out, "The downloaded %s archive failed verification and was discarded.\n", file) - _ = os.Remove(archive) - } - part := archive + ".part" + part := filepath.Join(dir, file+".tar.gz.part") for pass := 0; ; pass++ { resumed := false if info, err := os.Stat(part); err == nil && info.Size() > 0 { @@ -148,7 +111,7 @@ func fetchNativeArchive(ctx context.Context, out io.Writer, url, dir, file strin return "", err } if got == want { - return archive, os.Rename(part, archive) + return part, nil } _ = os.Remove(part) // A resumed file may have been damaged earlier; one clean download decides. @@ -329,10 +292,10 @@ func nativeFileDigest(ctx context.Context, path string) (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } -// extractNativeComponent unpacks components// from a verified archive into the distribution -// directory. Only regular files and directories under that path are accepted; per-file digests are -// checked later against bundle.json. -func extractNativeComponent(ctx context.Context, out io.Writer, archive, bundle, name string) error { +// extractNativeComponent unpacks components// from a verified archive into stage. Only regular +// files and directories under that path are accepted, and only files that bundle.json lists; every +// file is then checked against its digest. +func extractNativeComponent(ctx context.Context, out io.Writer, archive, stage, name string, expected nativeComponent) error { var size uint64 if err := eachNativeEntry(ctx, archive, name, func(h *tar.Header, _ string, _ io.Reader) error { size += uint64(h.Size) @@ -340,28 +303,27 @@ func extractNativeComponent(ctx context.Context, out io.Writer, archive, bundle, }); err != nil { return err } - parent := filepath.Join(bundle, "components") - if err := os.MkdirAll(parent, 0700); err != nil { - return err - } - if err := requireNativeSpace(parent, size); err != nil { + if err := requireNativeSpace(filepath.Dir(stage), size); err != nil { return err } fmt.Fprintf(out, "Extracting %s...\n", name) - stage := filepath.Join(parent, ".extract-"+name) - if err := os.RemoveAll(stage); err != nil { - return err - } - defer os.RemoveAll(stage) if err := eachNativeEntry(ctx, archive, name, func(h *tar.Header, rel string, r io.Reader) error { target := filepath.Join(stage, filepath.FromSlash(rel)) if h.Typeflag == tar.TypeDir { return os.MkdirAll(target, 0700) } + listed, ok := expected.Files[rel] + if !ok { + return errors.New("install: component archive holds a file that bundle.json does not list") + } if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { return err } - file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + mode := os.FileMode(0600) + if listed.Executable { + mode = 0700 + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) if err != nil { return err } @@ -373,7 +335,13 @@ func extractNativeComponent(ctx context.Context, out io.Writer, archive, bundle, }); err != nil { return err } - return os.Rename(stage, nativeComponentRoot(bundle, name)) + if err := checkComponentFiles(ctx, stage, expected); err != nil { + if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { + return errors.New("install: component archive does not match bundle.json; Core serves inconsistent installer files") + } + return err + } + return nil } // eachNativeEntry visits the files and directories of a component archive; the root directory itself is skipped. diff --git a/apps/daemon/internal/cli/native_download_test.go b/apps/daemon/internal/cli/native_download_test.go index 53d629d71..86b9f7c91 100644 --- a/apps/daemon/internal/cli/native_download_test.go +++ b/apps/daemon/internal/cli/native_download_test.go @@ -124,8 +124,7 @@ func writeBundle(t *testing.T, bundle string, components map[string]nativeCompon } func TestNativeInstallDownloadsOnlySelectedComponentsOnceAndResumes(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) + home := nativeTestHome(t) oldDelay, oldProbe := nativeRetryDelay, probeNativeInstallation nativeRetryDelay = time.Millisecond probeNativeInstallation = func(context.Context, string, []string) error { return nil } @@ -140,36 +139,47 @@ func TestNativeInstallDownloadsOnlySelectedComponentsOnceAndResumes(t *testing.T if err := os.MkdirAll(filepath.Join(cache, "older-version"), 0700); err != nil { t.Fatal(err) } - // Two Sessions of one release share the machine's cache. + // Two Sessions of one release on one machine download each component once, into one store. + var installations []string for i := 0; i < 2; i++ { - bundle := t.TempDir() + bundle, installation := t.TempDir(), t.TempDir() + installations = append(installations, installation) writeBundle(t, bundle, components) - if i == 1 { - // A damaged cached archive is discarded and fetched again. - if err := os.WriteFile(filepath.Join(cache, Version, runtime.GOOS+"-"+runtime.GOARCH+"-node.tar.gz"), []byte("damaged"), 0600); err != nil { - t.Fatal(err) - } - } - if err := nativeDownloadInstall(t, core, bundle, "--install-dir", t.TempDir()); err != nil { + if err := nativeDownloadInstall(t, core, bundle, "--install-dir", installation); err != nil { t.Fatal(err) } - if _, err := os.Stat(nativeComponentRoot(bundle, "claude")); err == nil { - t.Fatal("downloaded a Harness the Session does not use") + if _, err := os.Stat(filepath.Join(bundle, "components")); err == nil { + t.Fatal("extracted into the distribution directory") + } + if _, err := os.Stat(filepath.Join(installation, "components")); err == nil { + t.Fatal("copied components into the installation") } } - if core.bodies["node"] != 2 || core.bodies["claude"] != 0 { + if core.bodies["node"] != 1 || core.bodies["claude"] != 0 { t.Fatalf("unexpected downloads: %v", core.bodies) } if core.bodies["codex"] != 1 { - t.Fatalf("codex was fetched %d times, want its interrupted download resumed and then reused", core.bodies["codex"]) + t.Fatalf("codex was fetched %d times, want its interrupted download resumed", core.bodies["codex"]) + } + store := nativeTestStore(t) + for _, name := range []string{"node", "codex"} { + if err := verifyNativeComponent(context.Background(), filepath.Join(store, name), name, nil); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(store, "claude")); err == nil { + t.Fatal("downloaded a Harness the Sessions do not use") + } + if left, _ := filepath.Glob(filepath.Join(cache, Version, "*")); len(left) != 0 { + t.Fatalf("archives remain after extraction: %v", left) } if _, err := os.Stat(filepath.Join(cache, "older-version")); err == nil { - t.Fatal("archives of another version were kept") + t.Fatal("downloads of another version were kept") } } func TestNativeInstallRejectsGrantBeforeDownloading(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + nativeTestHome(t) core, _ := newFakeCore(t, t.TempDir()) core.status = http.StatusUnauthorized if err := nativeDownloadInstall(t, core, t.TempDir()); err == nil || !strings.Contains(err.Error(), "authorization rejected") { @@ -181,7 +191,7 @@ func TestNativeInstallRejectsGrantBeforeDownloading(t *testing.T) { } func TestNativeInstallRefusesRedirectToHTTP(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + nativeTestHome(t) core, _ := newFakeCore(t, t.TempDir()) components := map[string]nativeComponent{"node": core.add(t, "node"), "codex": core.add(t, "codex")} core.redirect = "http://127.0.0.1:1/archive.tar.gz" @@ -194,7 +204,7 @@ func TestNativeInstallRefusesRedirectToHTTP(t *testing.T) { func TestNativeInstallFromDistributionNamesMissingArchive(t *testing.T) { rc, args, _, bundle := nativeInstallFixture(t) - if err := os.RemoveAll(nativeComponentRoot(bundle, "codex")); err != nil { + if err := os.RemoveAll(bundleComponent(bundle, "codex")); err != nil { t.Fatal(err) } err := runInstall(rc, args) diff --git a/apps/daemon/internal/cli/native_harness.go b/apps/daemon/internal/cli/native_harness.go index 69aa94b91..34bc1f300 100644 --- a/apps/daemon/internal/cli/native_harness.go +++ b/apps/daemon/internal/cli/native_harness.go @@ -23,19 +23,19 @@ func nativeExe(name string) string { } return name } -func nativeNode(root string) string { - base := nativeComponentRoot(root, "node") +func nativeNode(components string) string { + base := filepath.Join(components, "node") if runtime.GOOS != "windows" { base = filepath.Join(base, "bin") } return filepath.Join(base, nativeExe("node")) } -func nativeHarnessEnvironment(root string, selected []string) map[string]string { - node := nativeNode(root) +func nativeHarnessEnvironment(components string, selected []string) map[string]string { + node := nativeNode(components) values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} for _, name := range selected { - dir := nativeComponentRoot(root, name) + dir := filepath.Join(components, name) values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] for key, value := range nativeHarnesses[name].Environment(dir, node) { values[key] = value @@ -73,10 +73,10 @@ var nativeHarnesses = map[string]agent.Installation{ } var probeNativeInstallation = checkNativeInstallation -func checkNativeInstallation(ctx context.Context, root string, selected []string) error { - env := withNativeEnv(nativeHarnessEnvironment(root, selected)) - node := nativeNode(root) - version, err := installroot.Probe(ctx, node, []string{"--version"}, env, root) +func checkNativeInstallation(ctx context.Context, components string, selected []string) error { + env := withNativeEnv(nativeHarnessEnvironment(components, selected)) + node := nativeNode(components) + version, err := installroot.Probe(ctx, node, []string{"--version"}, env, components) if err != nil || version != "v"+nativePins["node"] { return errors.New("install: bundled Node is unavailable or incompatible; use the matching native distribution") } @@ -85,7 +85,7 @@ func checkNativeInstallation(ctx context.Context, root string, selected []string if !ok || !spec.Supported() { return fmt.Errorf("install: %s is unsupported on this platform", name) } - if err = spec.Check(ctx, nativeComponentRoot(root, name), node, env); err != nil { + if err = spec.Check(ctx, filepath.Join(components, name), node, env); err != nil { return fmt.Errorf("install: %s; no installed files were replaced", err) } } diff --git a/apps/daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go index 663e93036..dbec5a2f2 100644 --- a/apps/daemon/internal/cli/native_install.go +++ b/apps/daemon/internal/cli/native_install.go @@ -7,6 +7,7 @@ import ( "errors" "flag" "fmt" + "io" "os" "path/filepath" "slices" @@ -25,6 +26,7 @@ type nativeInstallation struct { Workspace string `json:"workspace_directory"` Credential string `json:"credential_file"` CapabilityDirectory string `json:"capability_directory"` + ComponentDirectory string `json:"component_directory"` ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` Harnesses []string `json:"harnesses"` } @@ -54,6 +56,14 @@ func lockNativeInstallation(root string) (*os.Root, func(), error) { return held, func() { unlock(); held.Close() }, nil } +// validateNativeComponentDirectory accepts only a version directory of a components store. +func validateNativeComponentDirectory(dir string) error { + if runtimefs.ValidateLocalPath(dir) != nil || filepath.Base(dir) != Version || filepath.Base(filepath.Dir(dir)) != "components" { + return errors.New("invalid component directory") + } + return nil +} + func validateNativeInstallation(c nativeInstallation) error { if c.Version != Version { return errors.New("installation version is unsupported; preserve this installation and reinstall in a separate directory") @@ -69,6 +79,9 @@ func validateNativeInstallation(c nativeInstallation) error { return errors.New("install: --workspace, --credential-file and capability destination must be clean absolute paths") } } + if validateNativeComponentDirectory(c.ComponentDirectory) != nil { + return errors.New("installation component directory is invalid; reinstall in a separate directory") + } info, err := os.Stat(c.Workspace) if err != nil || !info.IsDir() { return errors.New("install: --workspace requires an existing directory") @@ -143,8 +156,33 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if err = cleanNativeTemporaryFiles(o.Directory); err != nil { return err } - // Download before claiming the credential, so a failed download leaves the Session's credential unclaimed. - if err = obtainNativeComponents(ctx, rc.stdout, o, selected); err != nil { + // A rerun keeps the shared components its installation already uses. + var previous nativeInstallation + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { + return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") + } + } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("install: cannot read existing installation") + } + if len(previous.Harnesses) > 0 { + if validateNativeComponentDirectory(previous.ComponentDirectory) != nil { + return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") + } + o.ComponentDirectory = previous.ComponentDirectory + } else if o.ComponentDirectory, err = nativeStoreDirectory(); err != nil { + return err + } + // An installation that did not publish its record must not pin the shared components. Removing + // the entry only lowers the count, so it needs no store lock; a later install or uninstall collects. + defer func() { + if _, err := os.Stat(filepath.Join(o.Directory, "daemon", "installation.json")); errors.Is(err, os.ErrNotExist) { + _ = os.Remove(nativeUserEntry(o.ComponentDirectory, o.Directory)) + } + }() + // Fetch before claiming the credential, so a failed download leaves the Session's credential unclaimed. + if err = ensureNativeComponents(ctx, rc.stdout, o, bundle, selected); err != nil { return err } if o.OnboardURL != "" { @@ -161,12 +199,7 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if err = validateNativeInstallation(o.nativeInstallation); err != nil { return err } - var previous nativeInstallation - raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) - if err == nil { - if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { - return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") - } + if len(previous.Harnesses) > 0 { wanted := o.nativeInstallation wanted.Harnesses = nil comparison := previous @@ -176,8 +209,6 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if !bytes.Equal(before, after) { return errors.New("install: existing connection settings differ; additive installation cannot replace them") } - } else if !errors.Is(err, os.ErrNotExist) { - return errors.New("install: cannot read existing installation") } all := append([]string{}, previous.Harnesses...) for _, name := range selected { @@ -186,22 +217,16 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO } } slices.Sort(all) - // Verify existing components before adding any new one; never repair or - // upgrade an installed dependency as a side effect of adding a Harness. - if len(previous.Harnesses) > 0 { - if err = nativeInstallPhase(rc.stdout, "Verifying installed components", func() error { return verifyNativeComponents(ctx, o.Directory, previous.Harnesses) }); err != nil { + // Harnesses installed earlier but not selected now were not touched above; they are verified, never repaired. + if untouched := slices.DeleteFunc(slices.Clone(previous.Harnesses), func(name string) bool { return slices.Contains(selected, name) }); len(untouched) > 0 { + if err = nativeInstallPhase(rc.stdout, "Verifying installed components", func() error { return verifyNativeComponents(ctx, o.ComponentDirectory, untouched) }); err != nil { return err } } if err = nativeInstallPhase(rc.stdout, "Installing Runtime", func() error { return installNativeBinary(ctx, o.Directory, len(previous.Harnesses) > 0) }); err != nil { return err } - for _, name := range append([]string{"node"}, selected...) { - if err = nativeInstallPhase(rc.stdout, "Installing "+name, func() error { return installNativeComponent(ctx, o.Bundle, o.Directory, name, bundle.Components[name]) }); err != nil { - return err - } - } - if err = nativeInstallPhase(rc.stdout, "Checking installed programs", func() error { return probeNativeInstallation(ctx, o.Directory, all) }); err != nil { + if err = nativeInstallPhase(rc.stdout, "Checking installed programs", func() error { return probeNativeInstallation(ctx, o.ComponentDirectory, all) }); err != nil { return err } if err = ctx.Err(); err != nil { @@ -220,20 +245,40 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO return nil } -func verifyNativeComponents(ctx context.Context, root string, selected []string) error { +// ensureNativeComponents registers the installation as a user of this version's shared components, +// collects what no installation uses, and installs the missing components. It holds the store lock. +func ensureNativeComponents(ctx context.Context, out io.Writer, o *nativeInstallOptions, bundle nativeBundle, selected []string) error { + unlock, err := lockNativeStore(ctx, out, filepath.Dir(o.ComponentDirectory)) + if err != nil { + return fmt.Errorf("install: %w", err) + } + defer unlock() + if err = requireNativeDirectory(o.ComponentDirectory); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("install: %w", err) + } + if err = addNativeUser(o.ComponentDirectory, o.Directory); err != nil { + return err + } + _ = collectNativeStore(filepath.Dir(o.ComponentDirectory)) + if err = cleanNativeStaging(o.ComponentDirectory); err != nil { + return err + } for _, name := range append([]string{"node"}, selected...) { + if err = ensureNativeComponent(ctx, out, o, name, bundle.Components[name]); err != nil { + return err + } + } + return nil +} + +// verifyNativeComponents checks node and the named Harnesses in the shared components directory. +func verifyNativeComponents(ctx context.Context, components string, harnesses []string) error { + for _, name := range append([]string{"node"}, harnesses...) { if _, ok := nativePins[name]; !ok { return errors.New("installation contains an unsupported Harness") } - c, err := componentReceipt(nativeComponentRoot(root, name)) - if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 { - return fmt.Errorf("installed %s is missing, modified or incompatible; reinstall separately (no automatic repair or upgrade)", name) - } - if err = checkComponentFiles(ctx, nativeComponentRoot(root, name), c); err != nil { - if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("installed %s is missing or modified; reinstall separately", name) - } - return fmt.Errorf("install: cannot verify installed %s: %w", name, err) + if err := verifyNativeComponent(ctx, filepath.Join(components, name), name, nil); err != nil { + return err } } return nil @@ -329,10 +374,10 @@ func runStart(rc *runContext, args []string) error { err = errors.New("start: no installed Harnesses; rerun install with --harness") } if err == nil { - err = verifyNativeComponents(ctx, root, config.Harnesses) + err = verifyNativeComponents(ctx, config.ComponentDirectory, config.Harnesses) } if err == nil { - err = probeNativeInstallation(ctx, root, config.Harnesses) + err = probeNativeInstallation(ctx, config.ComponentDirectory, config.Harnesses) } unlock() if err != nil { @@ -341,7 +386,7 @@ func runStart(rc *runContext, args []string) error { previousKinds := rc.installedKinds rc.installedKinds = nativeInstallationKinds(config.Harnesses) defer func() { rc.installedKinds = previousKinds }() - values := nativeHarnessEnvironment(root, config.Harnesses) + values := nativeHarnessEnvironment(config.ComponentDirectory, config.Harnesses) values["OAC_RUNTIME_WORKSPACE"] = config.Workspace values["OAC_RUNTIME_CAPABILITY_DIRECTORY"] = config.CapabilityDirectory values["OAC_RUNTIME_TOOL_ENV_FILE"] = config.ToolEnvironmentFile diff --git a/apps/daemon/internal/cli/native_install_interrupt_unix_test.go b/apps/daemon/internal/cli/native_install_interrupt_unix_test.go index d8a7e84da..049c12eee 100644 --- a/apps/daemon/internal/cli/native_install_interrupt_unix_test.go +++ b/apps/daemon/internal/cli/native_install_interrupt_unix_test.go @@ -44,7 +44,7 @@ func TestNativeInstallationInterruptReapsProbe(t *testing.T) { script := []byte("#!/bin/sh\nsleep 300 &\nprintf '%s %s' \"$$\" \"$!\" > \"$OAC_TEST_PROBE_PIDS\"\nwait\n") sum := sha256.Sum256(script) b.Components["node"] = nativeComponent{Version: nativePins["node"], Files: map[string]nativeFile{"bin/node": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} - dir := filepath.Join(nativeComponentRoot(bundle, "node"), "bin") + dir := filepath.Join(bundleComponent(bundle, "node"), "bin") if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/native_install_io.go b/apps/daemon/internal/cli/native_install_io.go index 999e71a4b..788d284c7 100644 --- a/apps/daemon/internal/cli/native_install_io.go +++ b/apps/daemon/internal/cli/native_install_io.go @@ -13,11 +13,11 @@ import ( ) // nativeInstallationEntries are the top-level entries an installation directory -// owns: the installer writes bin, daemon, components and the default +// owns: the installer writes bin, daemon and the default // capabilities, and Windows keeps the lock file of daemon beside it; the daemon // running from it adds runtime, initialization and packages. Install refuses a directory with anything else, and uninstall // deletes nothing else. -var nativeInstallationEntries = []string{"bin", "daemon", "components", "capabilities", ".daemon.runtime.lock", "runtime", "initialization", "packages"} +var nativeInstallationEntries = []string{"bin", "daemon", "capabilities", ".daemon.runtime.lock", "runtime", "initialization", "packages"} // foreignEntries names what dir holds that no installation owns. func foreignEntries(dir string) ([]string, error) { @@ -39,9 +39,8 @@ func foreignEntries(dir string) ([]string, error) { // These exact temporary names are reserved by the installer, never workspace data. var nativeTemporaryNames = map[string]*regexp.Regexp{ - "components": regexp.MustCompile(`^\.install-(node|codex|claude|minimax)-[0-9]+$`), - "bin": regexp.MustCompile(`^\.oac-daemon-[0-9]+$`), - "daemon": regexp.MustCompile(`^\.(installation\.json|executor-credential\.json)-[0-9a-f]{24}\.tmp$`), + "bin": regexp.MustCompile(`^\.oac-daemon-[0-9]+$`), + "daemon": regexp.MustCompile(`^\.(installation\.json|executor-credential\.json)-[0-9a-f]{24}\.tmp$`), } // The caller holds the installation lock, including while recovering a failed copy. diff --git a/apps/daemon/internal/cli/native_install_recovery_test.go b/apps/daemon/internal/cli/native_install_recovery_test.go index 12dabf52f..32c3c8962 100644 --- a/apps/daemon/internal/cli/native_install_recovery_test.go +++ b/apps/daemon/internal/cli/native_install_recovery_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "io" "os" "path/filepath" "strings" @@ -12,7 +13,7 @@ import ( func TestNativeInstallationCleansOnlyReservedPartialFiles(t *testing.T) { rc, args, root, _ := nativeInstallFixture(t) - names := []string{"components/.install-codex-12345/partial", "bin/.oac-daemon-9876", "daemon/.installation.json-0123456789abcdef01234567.tmp"} + names := []string{"bin/.oac-daemon-9876", "daemon/.installation.json-0123456789abcdef01234567.tmp"} for _, name := range names { path := filepath.Join(root, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { @@ -22,7 +23,7 @@ func TestNativeInstallationCleansOnlyReservedPartialFiles(t *testing.T) { t.Fatal(err) } } - keep := filepath.Join(root, "components", ".install-codex-user-notes") + keep := filepath.Join(root, "bin", ".oac-daemon-user-notes") if err := os.WriteFile(keep, []byte("keep"), 0600); err != nil { t.Fatal(err) } @@ -85,11 +86,11 @@ func TestNativeStagingCleanupDoesNotFollowLinks(t *testing.T) { if err := os.WriteFile(keep, []byte("keep"), 0600); err != nil { t.Fatal(err) } - parent := filepath.Join(root, "components") + parent := filepath.Join(root, "bin") if err := os.Mkdir(parent, 0700); err != nil { t.Fatal(err) } - if err := os.Symlink(outside, filepath.Join(parent, ".install-codex-123")); err != nil { + if err := os.Symlink(outside, filepath.Join(parent, ".oac-daemon-123")); err != nil { t.Skip("symlink permission unavailable") } if err := cleanNativeTemporaryFiles(root); err != nil { @@ -105,6 +106,7 @@ func TestNativeVerificationCancellationPreservesInstallation(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } + store := nativeTestStore(t) config := filepath.Join(root, "daemon", "installation.json") before, err := os.ReadFile(config) if err != nil { @@ -112,21 +114,21 @@ func TestNativeVerificationCancellationPreservesInstallation(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - if err = verifyNativeComponents(ctx, root, []string{"codex"}); !errors.Is(err, context.Canceled) { + if err = verifyNativeComponents(ctx, store, []string{"codex"}); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled verification reported damage: %v", err) } b, err := readNativeBundle(bundle, []string{"codex"}) if err != nil { t.Fatal(err) } - if err = installNativeComponent(ctx, bundle, root, "codex", b.Components["codex"]); !errors.Is(err, context.Canceled) { + if err = ensureNativeComponent(ctx, io.Discard, &nativeInstallOptions{nativeInstallation: nativeInstallation{ComponentDirectory: store}, Bundle: bundle}, "codex", b.Components["codex"]); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled reuse reported damage: %v", err) } after, err := os.ReadFile(config) if err != nil || !bytes.Equal(before, after) { t.Fatal("cancellation changed installation") } - if err = verifyNativeComponents(context.Background(), root, []string{"codex"}); err != nil { + if err = verifyNativeComponents(context.Background(), store, []string{"codex"}); err != nil { t.Fatal(err) } } diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index d423c8c4f..bae3f95b7 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "testing" @@ -17,12 +18,64 @@ import ( "github.com/google/uuid" ) +// nativeTestHome points the Runtime home at a fresh directory. The shared components are read-only, +// so cleanup makes them writable first. +func nativeTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", home) + t.Cleanup(func() { _ = removeNativeTree(home) }) + return home +} + +func bundleComponent(bundle, name string) string { + return filepath.Join(filepath.Join(bundle, "components"), name) +} + +func nativeTestStore(t *testing.T) string { + t.Helper() + store, err := nativeStoreDirectory() + if err != nil { + t.Fatal(err) + } + return store +} + +// otherNativeInstallation returns arguments for a second Session on the same machine and distribution. +func otherNativeInstallation(t *testing.T, args []string) []string { + t.Helper() + other := append([]string{}, args...) + environment := uuid.NewString() + key := filepath.Join(t.TempDir(), "credential.json") + body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) + if err := os.WriteFile(key, body, 0600); err != nil { + t.Fatal(err) + } + for flag, value := range map[string]string{"--environment-id": environment, "--credential-file": key, "--workspace": t.TempDir(), "--install-dir": t.TempDir()} { + other[slices.Index(other, flag)+1] = value + } + return other +} + +// uninstallNative runs the installed executable's uninstall for one installation directory. +func uninstallNative(t *testing.T, root string) string { + t.Helper() + old := os.Getenv("OAC_RUNTIME_HOME") + t.Setenv("OAC_RUNTIME_HOME", root) + defer os.Setenv("OAC_RUNTIME_HOME", old) + output := new(bytes.Buffer) + if err := runUninstall(&runContext{stdin: strings.NewReader("y\n"), stdout: output, stderr: output}, nil); err != nil { + t.Fatal(err) + } + return output.String() +} + func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) { t.Helper() root := t.TempDir() bundle := t.TempDir() workspace := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) + nativeTestHome(t) key := filepath.Join(t.TempDir(), "credential.json") environment := uuid.NewString() body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) @@ -34,7 +87,7 @@ func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) data := []byte("fixture " + name) sum := sha256.Sum256(data) b.Components[name] = nativeComponent{Version: nativePins[name], Files: map[string]nativeFile{"program": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} - dir := nativeComponentRoot(bundle, name) + dir := bundleComponent(bundle, name) if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } @@ -117,13 +170,17 @@ func TestNativeInstallationFailureAndRecoveryPreserveExisting(t *testing.T) { if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { t.Fatalf("could not reuse complete unpublished component: %v", err) } - // Source corruption cannot affect an existing component, and cannot be used - // to repair a modified installed one silently. - _ = os.WriteFile(filepath.Join(nativeComponentRoot(root, "codex"), "program"), []byte("changed"), 0700) - if err := runInstall(rc, args); err == nil { - t.Fatal("silently repaired modified executable") + // A modified shared component fails with the directory to delete and is never repaired silently. + component := filepath.Join(nativeTestStore(t), "codex") + program := filepath.Join(component, "program") + if err := os.Chmod(program, 0700); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(program, []byte("changed"), 0700) + if err := runInstall(rc, args); err == nil || !strings.Contains(err.Error(), component) || !strings.Contains(err.Error(), nativeDeleteCommand(component)) { + t.Fatalf("modified component: %v", err) } - if got, _ := os.ReadFile(filepath.Join(nativeComponentRoot(root, "codex"), "program")); string(got) != "changed" { + if got, _ := os.ReadFile(program); string(got) != "changed" { t.Fatal("overwrote installed data") } _ = bundle @@ -201,7 +258,7 @@ func TestNativeInstallationLockAndManifestContainment(t *testing.T) { if err = runInstall(rc, args); err == nil { t.Fatal("accepted manifest traversal") } - if _, err = os.Stat(filepath.Join(root, "components")); !errors.Is(err, os.ErrNotExist) { + if _, err = os.Stat(filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "components")); !errors.Is(err, os.ErrNotExist) { t.Fatal("manifest validation happened after mutation") } } @@ -248,10 +305,12 @@ func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } + store := nativeTestStore(t) + t.Setenv("OAC_RUNTIME_HOME", root) // The installed executable acts on its own installation. for _, mode := range []string{"installed", "missing-component"} { t.Run(mode, func(t *testing.T) { if mode == "missing-component" { - if err := os.Remove(filepath.Join(nativeComponentRoot(root, "codex"), "program")); err != nil { + if err := removeNativeTree(filepath.Join(store, "codex")); err != nil { t.Fatal(err) } } diff --git a/apps/daemon/internal/cli/native_start_interrupt_unix_test.go b/apps/daemon/internal/cli/native_start_interrupt_unix_test.go index 316498fda..5f639f826 100644 --- a/apps/daemon/internal/cli/native_start_interrupt_unix_test.go +++ b/apps/daemon/internal/cli/native_start_interrupt_unix_test.go @@ -57,7 +57,7 @@ func TestNativeStartInterruptCancelsEnrollment(t *testing.T) { t.Fatal(err) } cmd := exec.Command(os.Args[0], "-test.run=^TestNativeStartInterruptHelper$") - cmd.Env = append(os.Environ(), "OAC_TEST_START_INTERRUPT=1") + cmd.Env = append(os.Environ(), "OAC_TEST_START_INTERRUPT=1", "OAC_RUNTIME_HOME="+root) output := new(bytes.Buffer) cmd.Stdout, cmd.Stderr = output, output if err := cmd.Start(); err != nil { diff --git a/apps/daemon/internal/cli/native_store.go b/apps/daemon/internal/cli/native_store.go new file mode 100644 index 000000000..ccd03d257 --- /dev/null +++ b/apps/daemon/internal/cli/native_store.go @@ -0,0 +1,281 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" +) + +// The Runtime home keeps one installed copy of each component per daemon version, in +// components//. Every installation of that version on this machine uses it. +// The lock on components/ covers user entries, collection and installing missing components. +var ( + nativeStoreWait = 10 * time.Minute + nativeStoreRetry = time.Second +) + +func nativeStoreDirectory() (string, error) { + home, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(home, "components", Version), nil +} + +// requireNativeDirectory accepts a real directory owned by the current user, never a link. +func requireNativeDirectory(dir string) error { + info, err := os.Lstat(dir) + if err != nil { + return err + } + if !info.IsDir() || !ownedByCurrentUser(info) { + return fmt.Errorf("%s must be a directory owned by the current user, not a link", dir) + } + return nil +} + +// lockNativeStore waits for another process holding the store; any other lock failure is final. +func lockNativeStore(ctx context.Context, out io.Writer, root string) (func(), error) { + if _, err := os.Lstat(root); err == nil { + if err = requireNativeDirectory(root); err != nil { + return nil, err + } + } + if err := runtimefs.EnsurePrivateDir(root); err != nil { + return nil, err + } + held, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + deadline := time.Now().Add(nativeStoreWait) + for waiting := false; ; waiting = true { + unlock, err := runtimefs.LockDirectory(held) + if err == nil { + return func() { unlock(); held.Close() }, nil + } + if !errors.Is(err, runtimefs.ErrLocked) { + held.Close() + return nil, err + } + if time.Now().After(deadline) { + held.Close() + return nil, errors.New("another installation on this machine has held the shared components for 10 minutes; wait for it to finish or stop it, then rerun the command") + } + if !waiting { + fmt.Fprintln(out, "Waiting for another installation on this machine...") + } + select { + case <-ctx.Done(): + held.Close() + return nil, ctx.Err() + case <-time.After(nativeStoreRetry): + } + } +} + +// resolvedNativeDirectory is the spelling that user entries record and hash. +func resolvedNativeDirectory(dir string) string { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + return resolved + } + return dir +} + +// nativeUserEntry names the file that records an installation as a user of a version directory. +func nativeUserEntry(version, installation string) string { + sum := sha256.Sum256([]byte(resolvedNativeDirectory(installation))) + return filepath.Join(version, "users", hex.EncodeToString(sum[:])) +} + +// addNativeUser records the installation; an unchanged entry is left alone, a new one is written atomically. +func addNativeUser(version, installation string) error { + entry := nativeUserEntry(version, installation) + want := []byte(resolvedNativeDirectory(installation)) + if got, err := os.ReadFile(entry); err == nil && string(got) == string(want) { + return nil + } + dir := filepath.Dir(entry) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + held, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer held.Close() + return runtimefs.WritePrivateAtomic(held, filepath.Base(entry), want) +} + +// countNativeUsers drops the entries of installations whose directory is gone and counts the rest. +// Whatever it cannot read or parse counts as a user. managed is false for a directory without a +// real users directory, which this store did not create. +func countNativeUsers(version string) (users int, managed bool) { + dir := filepath.Join(version, "users") + if info, err := os.Lstat(dir); err != nil || !info.IsDir() { + return 0, false + } + entries, err := os.ReadDir(dir) + if err != nil { + return 1, true + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".") { + continue // an interrupted atomic write + } + file := filepath.Join(dir, entry.Name()) + // An entry is dropped only when its installation directory is gone. + if raw, err := os.ReadFile(file); err == nil && filepath.IsAbs(string(raw)) { + if _, err = os.Stat(string(raw)); errors.Is(err, os.ErrNotExist) && os.Remove(file) == nil { + continue + } + } + users++ + } + return users, true +} + +// collectNativeStore removes every version directory that no installation uses. A directory is +// renamed away before it is deleted, so a version is either whole or gone; a rename that fails, +// as on Windows while a file is in use, keeps the version. Leftovers of an interrupted deletion are swept. +func collectNativeStore(root string) error { + var failure error + entries, _ := os.ReadDir(root) + for _, entry := range entries { + dir := filepath.Join(root, entry.Name()) + if strings.HasPrefix(entry.Name(), ".remove-") { + _ = removeNativeTree(dir) + continue + } + if !entry.IsDir() { + continue // never follow a link out of the store + } + if users, managed := countNativeUsers(dir); managed && users == 0 { + if err := removeNativeVersion(dir); err != nil && failure == nil { + failure = err + } + } + } + return failure +} + +func removeNativeVersion(dir string) error { + gone := filepath.Join(filepath.Dir(dir), fmt.Sprintf(".remove-%d-%d", os.Getpid(), time.Now().UnixNano())) + if err := os.Rename(dir, gone); err != nil { + return fmt.Errorf("cannot remove %s: %w", dir, err) + } + return removeNativeTree(gone) +} + +// releaseNativeStore drops one installation's entry and collects the store. It returns how many +// other installations still use the version. +func releaseNativeStore(ctx context.Context, out io.Writer, version, entry string) (int, error) { + root := filepath.Dir(version) + if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { + return 0, nil + } + unlock, err := lockNativeStore(ctx, out, root) + if err != nil { + return 0, err + } + defer unlock() + if err = os.Remove(entry); err != nil && !errors.Is(err, os.ErrNotExist) { + return 0, err + } + if err = collectNativeStore(root); err != nil { + return 0, err + } + if _, err = os.Lstat(version); errors.Is(err, os.ErrNotExist) { + return 0, nil + } + users, _ := countNativeUsers(version) + if users == 0 { + return 0, fmt.Errorf("%s was not removed", version) + } + return users, nil +} + +// setNativeReadOnly makes the contents of a component unchangeable: files 0400 or, when +// executable, 0500, directories 0500. The top directory stays as it is, because macOS refuses to +// rename a directory that is not writable; sealNativeComponent closes it after the rename. +func setNativeReadOnly(path string) error { + var directories []string + err := filepath.WalkDir(path, func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if name != path { + directories = append(directories, name) + } + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + mode := os.FileMode(0400) + if info.Mode().Perm()&0100 != 0 { + mode = 0500 + } + return os.Chmod(name, mode) + }) + for i := len(directories) - 1; err == nil && i >= 0; i-- { + err = os.Chmod(directories[i], 0500) + } + return err +} + +func sealNativeComponent(dir string) error { return os.Chmod(dir, 0500) } + +// removeNativeTree deletes a tree that setNativeReadOnly protected. Go's RemoveAll +// cannot delete read-only entries on Windows and cannot enter read-only directories elsewhere. +func removeNativeTree(path string) error { + _ = filepath.WalkDir(path, func(name string, entry fs.DirEntry, err error) error { + if err != nil || entry.Type()&fs.ModeSymlink != 0 { + return nil + } + if entry.IsDir() { + return os.Chmod(name, 0700) + } + return os.Chmod(name, 0600) + }) + return os.RemoveAll(path) +} + +// nativeDeleteCommand is the command that deletes a read-only component directory by hand. +func nativeDeleteCommand(dir string) string { + if runtime.GOOS == "windows" { + return "Remove-Item -Recurse -Force '" + strings.ReplaceAll(dir, "'", "''") + "'" + } + quoted := "'" + strings.ReplaceAll(dir, "'", `'\''`) + "'" + return "chmod -R u+w " + quoted + " && rm -rf " + quoted +} + +// cleanNativeStaging removes the unpublished copies an interrupted installation left. The caller holds the store lock. +func cleanNativeStaging(version string) error { + entries, err := os.ReadDir(version) + if err != nil { + return err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".install-") { + if err = removeNativeTree(filepath.Join(version, entry.Name())); err != nil { + return fmt.Errorf("install: cannot remove interrupted staging: %w", err) + } + } + } + return nil +} diff --git a/apps/daemon/internal/cli/native_store_test.go b/apps/daemon/internal/cli/native_store_test.go new file mode 100644 index 000000000..a6debfd3d --- /dev/null +++ b/apps/daemon/internal/cli/native_store_test.go @@ -0,0 +1,195 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" +) + +func TestNativeInstallationsShareOneReadOnlyCopy(t *testing.T) { + rc, args, first, bundle := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + store := nativeTestStore(t) + // The second installation needs no distribution: it finds every component in the store. + if err := os.RemoveAll(filepath.Join(bundle, "components")); err != nil { + t.Fatal(err) + } + args = otherNativeInstallation(t, args) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + second := args[slices.Index(args, "--install-dir")+1] + for _, dir := range []string{first, second} { + if _, err := os.Stat(filepath.Join(dir, "components")); err == nil { + t.Fatal("installation holds its own component copy") + } + var installed nativeInstallation + if err := readNativeJSON(filepath.Join(dir, "daemon", "installation.json"), &installed); err != nil || installed.ComponentDirectory != store { + t.Fatalf("installation does not reference the store: %v %q", err, installed.ComponentDirectory) + } + } + if info, err := os.Stat(filepath.Join(store, "codex", ".oac-install.json")); err != nil || info.Mode().Perm()&0200 != 0 { + t.Fatalf("component file is writable: %v", err) + } + if users, _ := countNativeUsers(store); users != 2 { + t.Fatalf("store has %d users, want 2", users) + } + if output := uninstallNative(t, first); !strings.Contains(output, "Shared components kept: 1 other") { + t.Fatalf("first uninstall:\n%s", output) + } + if _, err := os.Stat(filepath.Join(store, "codex")); err != nil { + t.Fatal("uninstalling one installation removed components another uses") + } + if output := uninstallNative(t, second); !strings.Contains(output, "Shared components removed") { + t.Fatalf("last uninstall:\n%s", output) + } + if _, err := os.Stat(store); !os.IsNotExist(err) { + t.Fatal("the last uninstall left the version directory") + } +} + +func TestNativeStoreDropsGoneInstallationsAndUnusedVersions(t *testing.T) { + rc, args, first, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + store := nativeTestStore(t) + // A version whose only installation is gone, with read-only content like a published component. + old := filepath.Join(filepath.Dir(store), "old-version") + gone := filepath.Join(t.TempDir(), "deleted") + if err := addNativeUser(old, gone); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(old, "node"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "node", "program"), nil, 0400); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(old, "node"), 0500); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + if err := runInstall(rc, otherNativeInstallation(t, args)); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatal("unused version directory remains") + } + if users, _ := countNativeUsers(store); users != 1 { + t.Fatalf("store has %d users, want only the new installation", users) + } +} + +// safeBuffer lets the test read the output while the installation still writes it. +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *safeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *safeBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestNativeStoreWaitsForAnotherInstallation(t *testing.T) { + rc, args, _, _ := nativeInstallFixture(t) + output := &safeBuffer{} + rc.stdout = output + oldRetry, oldWait := nativeStoreRetry, nativeStoreWait + nativeStoreRetry = 5 * time.Millisecond + t.Cleanup(func() { nativeStoreRetry, nativeStoreWait = oldRetry, oldWait }) + root := filepath.Dir(nativeTestStore(t)) + unlock, err := lockNativeStore(context.Background(), io.Discard, root) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- runInstall(rc, args) }() + for deadline := time.Now().Add(10 * time.Second); !strings.Contains(output.String(), "Waiting for another installation"); { + select { + case err = <-done: + t.Fatalf("installation did not wait: %v", err) + case <-time.After(5 * time.Millisecond): + } + if time.Now().After(deadline) { + t.Fatal("the wait was not announced") + } + } + unlock() + if err = <-done; err != nil { + t.Fatal(err) + } + unlock, err = lockNativeStore(context.Background(), io.Discard, root) + if err != nil { + t.Fatal(err) + } + defer unlock() + nativeStoreWait = 20 * time.Millisecond + if _, err = lockNativeStore(context.Background(), io.Discard, root); err == nil || !strings.Contains(err.Error(), "held the shared components") { + t.Fatalf("expected a timeout, got %v", err) + } +} + +func TestNativeStoreKeepsWhatItDidNotCreateOrCannotParse(t *testing.T) { + rc, args, _, _ := nativeInstallFixture(t) + store := nativeTestStore(t) + // Another program's directory in components/ is not a version directory. + unrelated := filepath.Join(filepath.Dir(store), "unrelated") + if err := os.MkdirAll(unrelated, 0700); err != nil { + t.Fatal(err) + } + // A failed first installation leaves no entry; the version is collected by a later install. + probeNativeInstallation = func(context.Context, string, []string) error { return errors.New("synthetic unavailable") } + if err := runInstall(rc, args); err == nil { + t.Fatal("published a failed installation") + } + if users, _ := countNativeUsers(store); users != 0 { + t.Fatal("a failed first installation pinned the shared components") + } + probeNativeInstallation = func(context.Context, string, []string) error { return nil } + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + // An entry that cannot be read as a path counts as a user; a writable top directory is sealed again. + if err := os.WriteFile(filepath.Join(store, "users", "damaged"), nil, 0600); err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(filepath.Join(store, "codex"), 0700); err != nil { + t.Fatal(err) + } + } + if err := runInstall(rc, otherNativeInstallation(t, args)); err != nil { + t.Fatal(err) + } + if users, _ := countNativeUsers(store); users != 3 { + t.Fatalf("store has %d users, want 3", users) + } + if info, err := os.Stat(filepath.Join(store, "codex")); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0500) { + t.Fatalf("component directory not sealed: %v", err) + } + if _, err := os.Stat(unrelated); err != nil { + t.Fatal("collection removed a directory it did not create") + } +} diff --git a/apps/daemon/internal/cli/native_store_unix.go b/apps/daemon/internal/cli/native_store_unix.go new file mode 100644 index 000000000..13b1d54f0 --- /dev/null +++ b/apps/daemon/internal/cli/native_store_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package cli + +import ( + "os" + "syscall" +) + +func ownedByCurrentUser(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && int(stat.Uid) == os.Getuid() +} diff --git a/apps/daemon/internal/cli/native_store_windows.go b/apps/daemon/internal/cli/native_store_windows.go new file mode 100644 index 000000000..5cc6b43f0 --- /dev/null +++ b/apps/daemon/internal/cli/native_store_windows.go @@ -0,0 +1,6 @@ +package cli + +import "os" + +// Windows directory ACLs, not owners, decide access; the account's profile already scopes the store. +func ownedByCurrentUser(os.FileInfo) bool { return true } diff --git a/apps/daemon/internal/cli/native_uninstall.go b/apps/daemon/internal/cli/native_uninstall.go index 5ee504578..4e875d730 100644 --- a/apps/daemon/internal/cli/native_uninstall.go +++ b/apps/daemon/internal/cli/native_uninstall.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "context" "errors" "fmt" "os" @@ -39,6 +40,11 @@ func runUninstall(rc *runContext, args []string) error { if config.Workspace != "" && within(root, config.Workspace) { return fmt.Errorf("uninstall: the workspace %s is inside the installation directory; move it out first", config.Workspace) } + // The entry is named while the installation directory still exists. + var entry string + if validateNativeComponentDirectory(config.ComponentDirectory) == nil { + entry = nativeUserEntry(config.ComponentDirectory, root) + } var owned []string for _, name := range nativeInstallationEntries { if _, err = os.Lstat(filepath.Join(root, name)); err == nil { @@ -53,7 +59,10 @@ func runUninstall(rc *runContext, args []string) error { for _, target := range owned { fmt.Fprintln(rc.stdout, " "+target) } - fmt.Fprintln(rc.stdout, "These hold the executor credential, native history and Harnesses. The workspace stays, and so does anything else in "+root+" and any credential or capability directory outside the installation.") + if entry != "" { + fmt.Fprintln(rc.stdout, "The shared components of this version are deleted too when no other installation uses them: "+config.ComponentDirectory) + } + fmt.Fprintln(rc.stdout, "These hold the executor credential and native history. The workspace stays, and so does anything else in "+root+" and any credential or capability directory outside the installation.") fmt.Fprint(rc.stdout, "Continue? [y/N] ") answer, _ := bufio.NewReader(rc.stdin).ReadString('\n') if !strings.EqualFold(strings.TrimSpace(answer), "y") { @@ -87,6 +96,7 @@ func runUninstall(rc *runContext, args []string) error { bin := filepath.Join(root, "bin") order = append(order, bin, record) var kept bool + var releaseErr error for _, target := range order { held, e := removeTree(target, running) if e != nil { @@ -103,6 +113,17 @@ func runUninstall(rc *runContext, args []string) error { _ = os.Remove(root) } fmt.Fprintln(rc.stdout, "oac-daemon: uninstalled") + if entry != "" { + users, err := releaseNativeStore(context.Background(), rc.stdout, config.ComponentDirectory, entry) + if err != nil { + releaseErr = fmt.Errorf("uninstall: the installation was removed but its shared components could not be released (%w); the next install or uninstall on this machine collects them", err) + fmt.Fprintln(rc.stdout, "Warning: "+releaseErr.Error()) + } else if users == 0 { + fmt.Fprintln(rc.stdout, "Shared components removed: "+config.ComponentDirectory) + } else { + fmt.Fprintf(rc.stdout, "Shared components kept: %d other installation(s) still use %s\n", users, config.ComponentDirectory) + } + } if config.Workspace != "" { fmt.Fprintln(rc.stdout, "Workspace kept: "+config.Workspace) } @@ -115,7 +136,7 @@ func runUninstall(rc *runContext, args []string) error { } } fmt.Fprintf(rc.stdout, "Core still accepts this machine's credential. In Web, open the Session (Environment %s) and choose Revoke in Executor credentials, or Delete the Session. This Session's install command cannot be used again; to reconnect it, choose Rotate and install from an extracted distribution, or create a new Session.\n", config.Environment) - return nil + return releaseErr } // removeTree deletes path. keep is the running executable, which Windows diff --git a/apps/daemon/internal/cli/native_uninstall_test.go b/apps/daemon/internal/cli/native_uninstall_test.go index c5c789e0b..604d37e72 100644 --- a/apps/daemon/internal/cli/native_uninstall_test.go +++ b/apps/daemon/internal/cli/native_uninstall_test.go @@ -50,6 +50,7 @@ func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) { if err := os.WriteFile(foreignFile, []byte("keep"), 0600); err != nil { t.Fatal(err) } + t.Setenv("OAC_RUNTIME_HOME", root) output := new(bytes.Buffer) rc = &runContext{stdin: strings.NewReader("n\n"), stdout: output, stderr: output} if err := runUninstall(rc, nil); err == nil || !strings.Contains(output.String(), filepath.Join(root, "bin")) { @@ -63,7 +64,7 @@ func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) { if err := runUninstall(rc, nil); err != nil { t.Fatal(err) } - for _, text := range []string{"Revoke", "Executor credentials", "Delete the Session", "Kept: " + foreignFile, "Kept: " + foreignDir, "Kept: " + credential, "Rotate"} { + for _, text := range []string{"Revoke", "Executor credentials", "Delete the Session", "Kept: " + foreignFile, "Kept: " + foreignDir, "Kept: " + credential, "Rotate", "Shared components removed"} { if !strings.Contains(output.String(), text) { t.Fatalf("missing %q in\n%s", text, output) } @@ -90,10 +91,7 @@ func TestUninstallRemovesEmptiedInstallationDirectory(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } - rc = &runContext{stdin: strings.NewReader("y\n"), stdout: new(bytes.Buffer), stderr: new(bytes.Buffer)} - if err := runUninstall(rc, nil); err != nil { - t.Fatal(err) - } + uninstallNative(t, root) if _, err := os.Stat(root); !os.IsNotExist(err) { t.Fatal("installation directory remains") } @@ -109,7 +107,8 @@ func TestUninstallFailureCanBeRerun(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } - locked := filepath.Join(root, "components", "locked") + t.Setenv("OAC_RUNTIME_HOME", root) + locked := filepath.Join(root, "capabilities", "locked") if err := os.MkdirAll(locked, 0700); err != nil { t.Fatal(err) } @@ -157,13 +156,13 @@ func TestInstallRefusesDirectoryHoldingOtherFiles(t *testing.T) { // Without --install-dir an installation gets its own directory in the Runtime home. func TestInstallDefaultsToEnvironmentDirectory(t *testing.T) { - rc, args, root, _ := nativeInstallFixture(t) + rc, args, _, _ := nativeInstallFixture(t) args = slices.Delete(args, slices.Index(args, "--install-dir"), slices.Index(args, "--install-dir")+2) if err := runInstall(rc, args); err != nil { t.Fatal(err) } environment := args[slices.Index(args, "--environment-id")+1] - if _, err := os.Stat(filepath.Join(root, "environments", environment, "daemon", "installation.json")); err != nil { + if _, err := os.Stat(filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "environments", environment, "daemon", "installation.json")); err != nil { t.Fatalf("installation is not in the default directory: %v", err) } } diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 23ddfc8aa..9462b1c8d 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -25,10 +25,10 @@ The download depends on the Harnesses you install. The installer (9 MiB) and Nod | | One Harness | All three | | --- | --- | --- | -| Free space at the peak | about 1.3 GiB | about 2.1 GiB | -| Installed size | about 560 MiB | about 940 MiB | +| Free space at the peak | about 0.7 GiB | about 1.1 GiB | +| Installed size, once per machine and version | about 560 MiB | about 940 MiB | -With Codex, the peak is the archives (180 MiB), the unpacked installer (about 560 MiB) and the installed files (about 560 MiB). The unpacked copy is removed when the command ends. The archives stay in `native-download` under the Runtime home so that another Session of the same release on this machine downloads only the 9 MiB installer. +Components install once per machine and version, in `components/` under the Runtime home, and every Session of that version on the machine shares them. They are read-only, which prevents accidental changes; all Sessions run as the same account, so `start` verifies the components and refuses changed ones. Another Session of the same release downloads only the 9 MiB installer, installs no component again and adds only its own small directory. The peak is the installed size plus the archive being unpacked (121 MiB for Codex); each archive is deleted once its component is installed. The machine needs: @@ -86,7 +86,7 @@ The installer reports three results:
If the download stops -The installer checks the grant before it downloads a Harness, so an expired command costs only the 9 MiB installer. Each archive download resumes after a reset, fails only when it stalls (no data for 60 seconds) and gives up after five attempts; rerun the command to continue where it stopped. Verified archives stay in `native-download/` under the Runtime home. A later run or Session of the same version checks them against Core's checksum and reuses them, discarding any that fails; archives of other versions are removed. Two installations on one machine take turns downloading. Disk space is checked before downloading, extracting and copying components. If a download or installation is interrupted, rerun the command: it clears unfinished temporary copies while preserving completed components, credentials and the workspace. If the command expires, copy a fresh one from the Session. +The installer checks the grant before it downloads a Harness, so an expired command costs only the 9 MiB installer. Each archive download resumes after a reset, fails only when it stalls (no data for 60 seconds) and gives up after five attempts; rerun the command to continue where it stopped. An unfinished archive stays in `native-download/` under the Runtime home until a later run completes it, and downloads of other versions are removed. A component already installed on this machine is not downloaded again. Installations on one machine take turns installing components: the later one prints a waiting line and fails after 10 minutes. If a shared component was modified, `install` and `start` fail and name its directory: stop the installations that use it, delete that directory (the files are read-only: `chmod -R u+w && rm -rf `, or `Remove-Item -Recurse -Force ` in PowerShell) and run the install command again. Disk space is checked before downloading, extracting and copying components. If a download or installation is interrupted, rerun the command: it clears unfinished temporary copies while preserving completed components, credentials and the workspace. If the command expires, copy a fresh one from the Session.
@@ -163,7 +163,7 @@ On Windows PowerShell: & "$HOME\.oac\environments\\bin\oac-daemon.exe" uninstall ``` -It stops the daemon, lists what it deletes and asks for confirmation. It deletes only what an installation owns inside its directory (`bin`, `daemon`, `components`, `capabilities`, `runtime`, `initialization` and `packages`, with their credential file, native history and Harnesses) and then the directory itself if nothing else is left in it. Anything else in the directory stays, and so do the workspace and any credential file or capability directory outside the installation; the command names each one it keeps. The `native-download` cache is never touched; delete it to reclaim the space. Where nothing is installed it says so and exits 0. If a file cannot be deleted, the command names it and can be run again once it is released. On Windows, the final deletion finishes a few seconds after the command exits. +It stops the daemon, lists what it deletes and asks for confirmation. It deletes only what an installation owns inside its directory (`bin`, `daemon`, `capabilities`, `runtime`, `initialization` and `packages`, with their credential file and native history) and then the directory itself if nothing else is left in it. Anything else in the directory stays, and so do the workspace and any credential file or capability directory outside the installation; the command names each one it keeps. The shared components in the Runtime home are deleted with the last installation of that version on the machine; otherwise the command says how many other installations still use them. An installation directory deleted by hand is forgotten, and components nothing uses are removed, at the next install or uninstall on the machine. Unfinished downloads in `native-download` are never touched; delete that directory to reclaim the space. Where nothing is installed it says so and exits 0. If a file cannot be deleted, the command names it and can be run again once it is released. On Windows, the final deletion finishes a few seconds after the command exits. Then, in Web, open the Session and choose **Revoke** in **Executor credentials**, or **Delete** the Session; Core keeps accepting the machine's credential until you do. @@ -218,4 +218,4 @@ The same installer accepts an already extracted distribution and a credential fi "$HOME/.oac/my-runtime/bin/oac-daemon" start ``` -Use the Session's `remote_url` and Environment ID. The distribution is the directory described in [Reconnect an existing Session](#reconnect-an-existing-session): `oac-daemon` and `bundle.json` from the platform archive, and `components/` from the archives of `node` and each selected Harness. A missing component fails with the name of the archive to extract; this mode never downloads. In PowerShell, run `.\oac-daemon.exe` with native absolute paths. The installation directory must be missing, empty or hold an installation. This mode needs an existing workspace and does not start the daemon until you run `start`. +Use the Session's `remote_url` and Environment ID. The distribution is the directory described in [Reconnect an existing Session](#reconnect-an-existing-session): `oac-daemon` and `bundle.json` from the platform archive, and `components/` from the archives of `node` and each selected Harness. A component not yet installed on this machine is copied from the distribution, and one that is already installed is used as it is. A component missing from both fails with the name of the archive to extract; this mode never downloads. In PowerShell, run `.\oac-daemon.exe` with native absolute paths. The installation directory must be missing, empty or hold an installation. This mode needs an existing workspace and does not start the daemon until you run `start`. diff --git a/docs/zh/getting-started/self-hosted.md b/docs/zh/getting-started/self-hosted.md index 7220ae2ec..58c4faa25 100644 --- a/docs/zh/getting-started/self-hosted.md +++ b/docs/zh/getting-started/self-hosted.md @@ -1,7 +1,7 @@ --- title: "运行自托管 Session" source: docs/getting-started/self-hosted.md -source_hash: 3d3ec2e1374094c63bb7946440c50654bc5265bd0ef3808ecefbc0b014ee461f +source_hash: de2bf583004e0aabb735d434133780e26f96e9912dd7e11100327b2f83f403c4 --- `self_hosted` Session 在应用拥有的机器上运行,例如工作站、虚拟机或容器,而不是 Core 管理的沙箱。本指南面向用 `oac-daemon` 连接此类机器并保持其运行的应用开发者。 @@ -27,10 +27,10 @@ Session 自带模型提供商;安装默认模型不适用([原因](../../../ | | 一个 Harness | 全部三个 | | --- | --- | --- | -| 高峰时所需空闲空间 | 约 1.3 GiB | 约 2.1 GiB | -| 安装后占用 | 约 560 MiB | 约 940 MiB | +| 高峰时所需空闲空间 | 约 0.7 GiB | 约 1.1 GiB | +| 安装后占用,每台机器每个版本一份 | 约 560 MiB | 约 940 MiB | -选 Codex 时,高峰由归档(180 MiB)、解包后的安装程序(约 560 MiB)和安装后的文件(约 560 MiB)构成。解包副本在命令结束时删除。归档保留在 Runtime 主目录的 `native-download` 中,因此这台机器上同一发行版的另一个 Session 只需下载 9 MiB 的安装程序。 +组件按机器和版本只安装一份,位于 Runtime 主目录的 `components/` 中,这台机器上该版本的所有 Session 共用。它们是只读的,可防止意外改动;所有 Session 都以同一账号运行,因此 `start` 会校验这些组件,并拒绝已被改动的组件。同一发行版的另一个 Session 只需下载 9 MiB 的安装程序,不再安装任何组件,只增加自己的小目录。高峰为安装后占用加上正在解包的归档(Codex 为 121 MiB);每个归档在其组件安装完成后即删除。 机器需要: @@ -88,7 +88,7 @@ Session 自带模型提供商;安装默认模型不适用([原因](../../../
下载中断时 -安装程序在下载 Harness 之前先检查授权,因此过期的命令只会下载 9 MiB 的安装程序。每个归档的下载在连接重置后会续传,只在传输停滞(60 秒没有数据)时失败,最多尝试五次;重新执行命令即可从中断处继续。校验通过的归档保存在 Runtime 主目录的 `native-download/` 中。同一版本的后续运行或 Session 会对照 Core 提供的校验和检查并复用它们,未通过的会被丢弃;其他版本的归档会被删除。同一台机器上的两个安装会轮流下载。下载、解压和复制组件前会检查磁盘空间。如果下载或安装被中断,重新执行命令:它清理未完成的临时副本,同时保留已完成的组件、凭据和工作区。命令过期时,从 Session 复制新命令。 +安装程序在下载 Harness 之前先检查授权,因此过期的命令只会下载 9 MiB 的安装程序。每个归档的下载在连接重置后会续传,只在传输停滞(60 秒没有数据)时失败,最多尝试五次;重新执行命令即可从中断处继续。未完成的归档保存在 Runtime 主目录的 `native-download/` 中,直到之后的运行将其完成;其他版本的下载会被删除。这台机器上已安装的组件不会再次下载。同一台机器上的安装轮流安装组件:后到的会输出等待提示,10 分钟后失败。如果某个共享组件被修改,`install` 和 `start` 会失败并指出它的目录:停止使用它的安装,删除该目录(文件是只读的:`chmod -R u+w && rm -rf `,在 PowerShell 中用 `Remove-Item -Recurse -Force `),再重新执行安装命令。下载、解压和复制组件前会检查磁盘空间。如果下载或安装被中断,重新执行命令:它清理未完成的临时副本,同时保留已完成的组件、凭据和工作区。命令过期时,从 Session 复制新命令。
@@ -165,7 +165,7 @@ Session 自带模型提供商;安装默认模型不适用([原因](../../../ & "$HOME\.oac\environments\\bin\oac-daemon.exe" uninstall ``` -它会停止守护进程,列出将删除的内容并请求确认。它只删除安装目录中属于该安装的条目(`bin`、`daemon`、`components`、`capabilities`、`runtime`、`initialization` 和 `packages`,含凭据文件、原生历史和 Harness),之后若目录中没有其他内容,再删除目录本身。目录中的其他内容会保留,工作区以及安装目录之外的凭据文件或能力目录也会保留;命令会逐一列出保留的路径。`native-download` 缓存不会被触碰;如需回收空间,请自行删除。在没有安装的位置运行时,它会说明这一点并以 0 退出。如果某个文件无法删除,命令会指出它,待其被释放后可再次运行。在 Windows 上,最后的删除会在命令退出几秒后完成。 +它会停止守护进程,列出将删除的内容并请求确认。它只删除安装目录中属于该安装的条目(`bin`、`daemon`、`capabilities`、`runtime`、`initialization` 和 `packages`,含凭据文件和原生历史),之后若目录中没有其他内容,再删除目录本身。目录中的其他内容会保留,工作区以及安装目录之外的凭据文件或能力目录也会保留;命令会逐一列出保留的路径。Runtime 主目录中的共享组件随该版本在这台机器上的最后一个安装一并删除;否则命令会说明还有多少其他安装在使用它们。手动删除的安装目录会在这台机器上的下一次安装或卸载时被忘记,无人使用的组件也在那时删除。`native-download` 中未完成的下载不会被触碰;如需回收空间,请删除该目录。在没有安装的位置运行时,它会说明这一点并以 0 退出。如果某个文件无法删除,命令会指出它,待其被释放后可再次运行。在 Windows 上,最后的删除会在命令退出几秒后完成。 随后在 Web 中打开该 Session,在 **执行器凭据** 中选择 **撤销**,或 **删除** 该 Session;在此之前,Core 仍接受这台机器的凭据。 @@ -220,4 +220,4 @@ session = client.beta.agents.sessions.create( "$HOME/.oac/my-runtime/bin/oac-daemon" start ``` -使用 Session 的 `remote_url` 和 Environment ID。发行包即[重新连接已有 Session](#reconnect-an-existing-session) 中描述的目录:平台归档中的 `oac-daemon` 和 `bundle.json`,以及 `node` 和每个所选 Harness 的归档中的 `components/`。缺少组件会失败,并指出需要解压的归档;此模式从不下载。在 PowerShell 中,以原生绝对路径运行 `.\oac-daemon.exe`。安装目录必须不存在、为空,或已包含一个安装。此模式要求工作区已存在,且在运行 `start` 前不会启动守护进程。 +使用 Session 的 `remote_url` 和 Environment ID。发行包即[重新连接已有 Session](#reconnect-an-existing-session) 中描述的目录:平台归档中的 `oac-daemon` 和 `bundle.json`,以及 `node` 和每个所选 Harness 的归档中的 `components/`。这台机器上尚未安装的组件会从发行包复制,已安装的则直接使用。两处都缺少的组件会失败,并指出需要解压的归档;此模式从不下载。在 PowerShell 中,以原生绝对路径运行 `.\oac-daemon.exe`。安装目录必须不存在、为空,或已包含一个安装。此模式要求工作区已存在,且在运行 `start` 前不会启动守护进程。 diff --git a/internal/runtimefs/runtimefs.go b/internal/runtimefs/runtimefs.go index 093369431..f1ee01867 100644 --- a/internal/runtimefs/runtimefs.go +++ b/internal/runtimefs/runtimefs.go @@ -13,6 +13,9 @@ import ( var ErrUnsafe = errors.New("Runtime state path unavailable or invalid") +// ErrLocked reports that another process holds the directory lock. +var ErrLocked = errors.New("directory is locked by another process") + func validName(name string) bool { return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, "/\\\x00") } diff --git a/internal/runtimefs/runtimefs_test.go b/internal/runtimefs/runtimefs_test.go index 8a25f4ac5..646f8c61c 100644 --- a/internal/runtimefs/runtimefs_test.go +++ b/internal/runtimefs/runtimefs_test.go @@ -96,6 +96,9 @@ func TestPrivateLockChild(t *testing.T) { unlock() t.Fatal("concurrent lock admitted") } + if !errors.Is(err, ErrLocked) { + t.Fatalf("a held lock must report ErrLocked, got %v", err) + } } else { if err != nil { t.Fatal(err) diff --git a/internal/runtimefs/runtimefs_unix.go b/internal/runtimefs/runtimefs_unix.go index 91448d0f0..eeb0a49da 100644 --- a/internal/runtimefs/runtimefs_unix.go +++ b/internal/runtimefs/runtimefs_unix.go @@ -3,6 +3,8 @@ package runtimefs import ( + "errors" + "fmt" "os" "path/filepath" "strings" @@ -23,6 +25,9 @@ func LockDirectory(root *os.Root) (func(), error) { } if err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, fmt.Errorf("%w: %w", ErrLocked, err) + } return nil, err } return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }, nil diff --git a/internal/runtimefs/runtimefs_windows.go b/internal/runtimefs/runtimefs_windows.go index 6d79718b0..20b08761a 100644 --- a/internal/runtimefs/runtimefs_windows.go +++ b/internal/runtimefs/runtimefs_windows.go @@ -1,6 +1,8 @@ package runtimefs import ( + "errors" + "fmt" "os" "path/filepath" "strings" @@ -38,6 +40,9 @@ func LockDirectory(root *os.Root) (func(), error) { overlapped := new(windows.Overlapped) if err = windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped); err != nil { f.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return nil, fmt.Errorf("%w: %w", ErrLocked, err) + } return nil, err } return func() { _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped); _ = f.Close() }, nil diff --git a/scripts/build-native-installer-ci.mjs b/scripts/build-native-installer-ci.mjs index a77e46c18..4daeff856 100644 --- a/scripts/build-native-installer-ci.mjs +++ b/scripts/build-native-installer-ci.mjs @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { access, mkdir, readdir, realpath, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { access, mkdir, readdir, realpath, stat, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { archiveBundle, buildBundle } from './build-native-installer.mjs'; @@ -54,6 +55,13 @@ const allHarnesses = windows ? 'codex,claude' : 'codex,claude,minimax'; install(['--harness', allHarnesses], true); install(['--harness', allHarnesses], true); if (windows) install(['--harness', 'minimax'], false); +// Components are installed once per machine and version, read-only, and the installation only references them. +const store = join(env.OAC_RUNTIME_HOME, 'components', receipt.daemon_version); +for (const name of ['node', ...allHarnesses.split(',')]) { + assert.ok(existsSync(join(store, name, '.oac-install.json')), `Shared ${name} component is missing`); + assert.equal((await stat(join(store, name, '.oac-install.json'))).mode & 0o200, 0, `Shared ${name} component is writable`); +} +assert.ok(!existsSync(join(installDir, 'components')), 'The installation holds its own component copy'); const installedCommand = join(installDir, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon'); const installedEnv = { ...process.env }; delete installedEnv.OAC_RUNTIME_HOME; const installedVersion = spawnSync(installedCommand, ['version'], { env: installedEnv, encoding: 'utf8', timeout: 10000 }); @@ -62,4 +70,23 @@ assert.equal(installedVersion.stdout.trim(), receipt.daemon_version, 'Installed const incomplete = spawnSync(command, ['install', '--non-interactive'], { env, input: '', encoding: 'utf8', timeout: 10000 }); assert.equal(Boolean(incomplete.error), false, 'Missing arguments waited for interaction'); assert.notEqual(incomplete.status, 0, 'Missing required arguments succeeded'); +// A second Session on the machine shares the same components; uninstalling both removes them. +const secondEnvironment = randomUUID(); +const secondCredential = join(root, 'native-install-second-credential.json'); +await writeFile(secondCredential, JSON.stringify({ key_id: randomUUID(), executor_token: 'fixture-only-not-a-real-credential', environment_id: secondEnvironment }), { mode: 0o600 }); +const secondWorkspace = join(root, 'native-install-second-workspace'); await mkdir(secondWorkspace); +const secondDir = join(root, 'native-install-second'); +const second = spawnSync(command, ['install', '--non-interactive', '--bundle-dir', bundle, '--harness', allHarnesses, '--install-dir', secondDir, '--remote', 'ws://localhost:1/api/v1/agent-daemon/ws', + '--environment-id', secondEnvironment, '--workspace', secondWorkspace, '--credential-file', secondCredential], { env, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 }); +assert.equal(second.status, 0, 'Second installation failed'); +assert.ok(!existsSync(join(secondDir, 'components')), 'The second installation holds its own component copy'); +function uninstall(directory) { + const result = spawnSync(join(directory, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon'), ['uninstall'], { env: installedEnv, input: 'y\n', encoding: 'utf8', timeout: 120000 }); + assert.equal(result.status, 0, 'Uninstall failed'); + return result.stdout; +} +assert.match(uninstall(installDir), /Shared components kept: 1 other/); +assert.ok(existsSync(join(store, 'node', '.oac-install.json')), 'Uninstalling one installation removed components another uses'); +assert.match(uninstall(secondDir), /Shared components removed/); +assert.ok(!existsSync(store), 'The last uninstall left the shared components'); console.log(JSON.stringify({ stage: 'install', harnesses: allHarnesses.split(','), install: 'passed', additive: 'passed', reuse: 'passed', missing_arguments: 'rejected', connection: 'not_attempted', model: 'not_configured', model_requests: 0 })); diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs index cb17bc355..05bf83c73 100644 --- a/scripts/native-onboarding-smoke.mjs +++ b/scripts/native-onboarding-smoke.mjs @@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { createServer } from 'node:http'; import { spawn, execFileSync } from 'node:child_process'; import { createReadStream, existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; const root = process.env.RUNNER_TEMP; @@ -21,6 +21,7 @@ const installation = join(root, 'onboarding-installation'); const archives = join(root, 'native-archives'); const platform = `${manifest.os}-${manifest.arch}`; const checksums = new Map(), downloaded = new Set(); +let componentRequests = 0; async function checksum(name) { if (!checksums.has(name)) { const digest = createHash('sha256'); @@ -38,7 +39,7 @@ const server = createServer(async (request, response) => { if (artifact) { if (!existsSync(join(archives, `${artifact[1]}.tar.gz`))) return json(404, {}); if (artifact[2] === 'sha256') { response.setHeader('Content-Type', 'text/plain; charset=utf-8'); return response.end(await checksum(artifact[1])+'\n'); } - downloaded.add(artifact[1]); + downloaded.add(artifact[1]); if (artifact[1] !== platform) componentRequests++; return createReadStream(join(archives, `${artifact[1]}.tar.gz`)).pipe(response); } if (url.pathname.endsWith('bootstrap.sh') || url.pathname.endsWith('bootstrap.ps1')) return createReadStream(resolve('services/core/internal/nativeinstaller/assets', url.pathname.split('/').at(-1))).pipe(response); @@ -92,6 +93,9 @@ try { assert.notEqual((await run(executable, [...args, '--non-interactive', '--harness', 'codex'])).code, 0, 'Lost claim response should fail safely'); const saved = JSON.parse(await readFile(join(installation, 'daemon', 'executor-credential.json'), 'utf8')); assert.equal(saved.executor_token, secret, 'Secret must survive a lost response'); + // A failed first installation leaves no user entry, so it pins nothing; its components stay for the next run. + const store = join(root, 'unused-onboarding-home', 'components', manifest.daemon_version); + assert.deepEqual(existsSync(join(store, 'users')) ? await readdir(join(store, 'users')) : [], [], 'A failed installation is still registered as a user'); const script = resolve(`services/core/internal/nativeinstaller/assets/bootstrap.${windows ? 'ps1' : 'sh'}`); const bootstrapArgs = windows ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Base', base] : [script, base]; // Select every Harness of this platform so each Harness archive passes the installer's extraction and readiness checks. @@ -99,15 +103,24 @@ try { const result = await run(windows ? 'powershell.exe' : 'bash', [...bootstrapArgs, '--install-dir', installation], harnesses.join(',') + '\n\n'); assert.equal(result.code, 0, `Interactive bootstrap failed: ${result.output}`); assert.match(result.output, /Daemon connection: connected/); - assert.deepEqual([...downloaded].sort(), [platform, ...['node', ...harnesses].map(name => `${platform}-${name}`)].sort(), 'Only the base, Node and the selected Harnesses may be downloaded'); + // The failed runs installed Node and Codex from the bundle; the bootstrap reuses them. + assert.deepEqual([...downloaded].sort(), [platform, ...harnesses.filter(name => name !== 'codex').map(name => `${platform}-${name}`)].sort(), 'Only the base and the missing Harnesses may be downloaded'); assert.match(result.output, /Model configuration: not checked/); + // Components live once per machine and version, outside the installation. + assert.ok(existsSync(join(store, 'codex', '.oac-install.json')), 'Shared Codex component is missing'); + assert.ok(!existsSync(join(installation, 'components')), 'The installation holds its own component copy'); + assert.ok(!existsSync(join(root, 'unused-onboarding-home', 'native-download', manifest.daemon_version, `${platform}-codex.tar.gz.part`)), 'An archive remains after installation'); assert.equal(connections, 1); + const downloads = componentRequests; const repeat = await run(windows ? 'powershell.exe' : 'bash', [...bootstrapArgs, '--install-dir', installation, '--non-interactive', '--harness', 'codex']); assert.equal(repeat.code, 0, `Repeat failed: ${repeat.output}`); assert.equal(connections, 1, 'Repeated installation created a duplicate daemon'); + assert.equal(componentRequests, downloads, 'A repeated installation downloaded a component'); const uninstall = await run(join(installation, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon'), ['uninstall'], 'y\n'); assert.equal(uninstall.code, 0, `Uninstall failed: ${uninstall.output}`); assert.match(uninstall.output, /Revoke/); + assert.match(uninstall.output, /Shared components removed/); + assert.ok(!existsSync(store), 'Uninstall left the shared components'); // Windows finishes deleting its own executable after the command exits. for (let wait = 0; existsSync(installation) && wait < 100; wait++) await new Promise(resolve => setTimeout(resolve, 100)); assert.ok(!existsSync(installation), 'Uninstall left the installation'); diff --git a/services/core/internal/nativeinstaller/assets/bootstrap.ps1 b/services/core/internal/nativeinstaller/assets/bootstrap.ps1 index d713b44d6..ab25906f9 100644 --- a/services/core/internal/nativeinstaller/assets/bootstrap.ps1 +++ b/services/core/internal/nativeinstaller/assets/bootstrap.ps1 @@ -24,7 +24,7 @@ function Get-NativeFile([string]$Uri, [string]$Destination) { } } } -# The installer downloads the Harness archives into the download directory and extracts them beside the executable. +# The unpacked installer lives here; the installer also keeps unfinished component downloads in this directory. $cache = Join-Path ([IO.Path]::GetFullPath($root)) 'native-download' $work = Join-Path $cache ('bundle.' + [Guid]::NewGuid().ToString('N').Substring(0, 8)) try { diff --git a/services/core/internal/nativeinstaller/assets/bootstrap.sh b/services/core/internal/nativeinstaller/assets/bootstrap.sh index d9fa9b3aa..f381f3f91 100644 --- a/services/core/internal/nativeinstaller/assets/bootstrap.sh +++ b/services/core/internal/nativeinstaller/assets/bootstrap.sh @@ -20,7 +20,7 @@ cache=$root/native-download mkdir -p "$cache" [ -d "$cache" ] && [ -O "$cache" ] || fail 'Native download directory must belong to the current account.' chmod 700 "$cache" -# The installer downloads the Harness archives into this directory and extracts them beside the executable. +# The unpacked installer lives here; the installer also keeps unfinished component downloads in this directory. work=$(mktemp -d "$cache/bundle.XXXXXX") || fail 'Cannot write the native download directory.' active= cleanup() { rm -rf "$work"; }