Merge branch 'v3' into v3-t76
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
You can see how the sausage is made.
|
||||
|
||||
**v3:** Read [Run Sausage v3 on Citadel](docs/RUNNING.md) for the console, approvals, and current bootstrap gaps.
|
||||
The rest of this README describes v2.
|
||||
|
||||
Sausage is a world of small WASI programs ("plugins") that you grow by
|
||||
talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call
|
||||
to a plugin starts a new process. The agent is a plugin too: it writes new
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
# Run Sausage v3 on Citadel
|
||||
|
||||
The console is the UI. The browser handles credential approvals, not chat.
|
||||
|
||||
**TL;DR:** Use a separate v3 world and the release host. Keep its complete folder between starts.
|
||||
The development bundle now seeds a complete source tree, including agents, skills, and extensions.
|
||||
The Nix package does not yet supply that bundle. Verify the remaining cutover steps below before production use.
|
||||
The rest of the root README describes v2.
|
||||
|
||||
## Build and start
|
||||
|
||||
Run host commands from a checkout of the `v3` branch on Citadel.
|
||||
Use Nix with flakes. The package supplies the native Rust builder and its tools.
|
||||
|
||||
The package entry point is:
|
||||
|
||||
```sh
|
||||
nix run . -- run "$HOME/sausage-v3" --userland "$SAUSAGE_USERLAND"
|
||||
```
|
||||
|
||||
`SAUSAGE_USERLAND` must name an assembled bundle with `bin/` and `src/` directories.
|
||||
It is not a world folder. The CLI also accepts this variable without `--userland`.
|
||||
The package does not set it for you.
|
||||
|
||||
The source-tree bundle builder is:
|
||||
|
||||
```sh
|
||||
nix develop -c nu userland/bundle.nu
|
||||
```
|
||||
|
||||
It builds the world commands and creates `target/userland/bundle` by default.
|
||||
It snapshots committed source, not uncommitted edits.
|
||||
The source archive includes the complete `agents/`, `skills/`, and `extensions/` directories.
|
||||
The world copier preserves symlinks, including dangling links in the source tree.
|
||||
A fresh development world no longer needs a manual source copy.
|
||||
|
||||
For a release host from the checkout:
|
||||
|
||||
```sh
|
||||
nix develop -c cargo build -p sausage --release --locked
|
||||
```
|
||||
|
||||
Cargo puts the host under `target/release/sausage`, unless `CARGO_TARGET_DIR` selects another directory.
|
||||
Use that binary with the same `run WORLD --userland BUNDLE` arguments.
|
||||
Use the repository's build slots and shared executor target directory for agent validation builds.
|
||||
|
||||
Choose `$HOME/sausage-v3`, not the live v2 `~/world`.
|
||||
For the actual cutover, let v2 finish its work before you stop it.
|
||||
Do not run two production services. This guide does not stop or replace the deployed service.
|
||||
The host creates this layout:
|
||||
|
||||
| Host path | Purpose |
|
||||
| --- | --- |
|
||||
| `sausage-v3/world/` | The world root `/`, including programs and editable source. |
|
||||
| `sausage-v3/host/` | Private host state, including credentials, harness storage, undo, and builder caches. |
|
||||
|
||||
The host creates `/run` in memory. It generates `/proc` at runtime.
|
||||
It copies a bundle only when `WORLD/world` does not exist.
|
||||
A later `--userland` does not update an existing world.
|
||||
Even a failed initial start can create `WORLD/world`. Do not assume another start will seed it.
|
||||
Keep both `world/` and `host/` for recovery. Never copy secrets into the world.
|
||||
|
||||
Sources: [`main.rs`](../host/src/main.rs), [`world.rs`](../host/src/world.rs),
|
||||
[`flake.nix`](../flake.nix), and [`bundle.nu`](../userland/bundle.nu).
|
||||
|
||||
## Use and stop the console
|
||||
|
||||
Each nonempty console line submits a message to the durable root conversation.
|
||||
Assistant text appears on stdout. This is not a shell or the v2 command dispatcher.
|
||||
Text such as `quit`, `help`, and `core.restart` has no special host meaning.
|
||||
Ask the agent to execute world commands when you need tools.
|
||||
|
||||
Press **Ctrl-C** to stop the host. SIGTERM also requests orderly shutdown.
|
||||
EOF only detaches the console input. It does not stop the host.
|
||||
Keep the foreground terminal available if you want to continue chat.
|
||||
|
||||
After a crash, repeat the same start command with the same world folder.
|
||||
The host opens `host/harness.sqlite`, restores extensions, and resumes durable work.
|
||||
It does not replay the historical transcript into the console.
|
||||
It cannot restore ordinary process memory or reverse completed network effects.
|
||||
There is no automatic restart supervisor in this guide.
|
||||
The existing flake loop still contains v2 assumptions. Do not use its no-argument mode for v3.
|
||||
|
||||
Sources: [`input.rs`](../host/src/harness/input.rs), [`main.ts`](../host/js/main.ts),
|
||||
and [`harness/mod.rs`](../host/src/harness/mod.rs).
|
||||
|
||||
## Reach the approval pages
|
||||
|
||||
The host listens on `127.0.0.1:8081` by default.
|
||||
If another service owns port 8081, choose another host port with `--api-listen`.
|
||||
Adjust the tunnel's remote port, but retain laptop port 8081 for the default origin.
|
||||
From the laptop, create an SSH tunnel to Citadel:
|
||||
|
||||
```sh
|
||||
ssh -N -L 8081:127.0.0.1:8081 citadel.vpn.surma.technology
|
||||
```
|
||||
|
||||
Open **http://localhost:8081/register** on the laptop.
|
||||
Keep the laptop port at 8081 to match the configured browser origin.
|
||||
The defaults are `--api-rp-id localhost` and `--api-origin http://localhost:8081`.
|
||||
|
||||
For direct VPN access, the default loopback listener is insufficient.
|
||||
You need a reachable listener, HTTPS termination, and matching RP ID and origin options.
|
||||
Plain HTTP on the VPN hostname does not provide the browser's secure context for passkeys.
|
||||
The SSH tunnel avoids that extra setup for localhost tests.
|
||||
**To check with Surma:** the laptop tunnel, browser passkey registration, secret entry, and request approval.
|
||||
The planned production origin is `https://sausage.apps.surma.technology`, with RP ID `sausage.apps.surma.technology`.
|
||||
Passkeys registered for `localhost` do not transfer to that RP ID. Plan a separate production registration.
|
||||
Do not expose the listener before the first registration to people who must not approve requests.
|
||||
|
||||
### Register, create an item, enter a secret
|
||||
|
||||
1. Open `/register` and choose a label for the first passkey.
|
||||
2. Complete the browser's passkey ceremony.
|
||||
3. Ask the agent to file an item creation request through `POST /cred/requests`.
|
||||
4. Review the request on `/approve/REQUEST_ID` and approve it with your passkey.
|
||||
5. Read the item's public metadata to find its item ID and stable field ID.
|
||||
6. Open `/entry/ITEM_ID/FIELD_ID` on the host origin.
|
||||
7. Enter the secret in the blank host form and complete its passkey ceremony.
|
||||
|
||||
Until the first passkey exists, D53 permits unlocked decisions and first registration.
|
||||
After registration, decisions require a passkey. Additional registrations require an existing passkey.
|
||||
Secret entry requires a passkey even during bootstrap.
|
||||
The browser encrypts the secret before submission. The world never receives its plaintext.
|
||||
Do not paste secrets into chat or world files.
|
||||
|
||||
There is no item editor or credential dashboard yet.
|
||||
Item creation uses `kind: "edit"` and a stable request key.
|
||||
Its request has `item: null`, `base_version: null`, and a `change` array.
|
||||
The creation operation is `{op: "create_item", config: ITEM}`.
|
||||
`ITEM` must contain the complete public item configuration, including its fields, rules, and transformation.
|
||||
Further edits add fields and rules. Inspect the typed operations before you construct those edits.
|
||||
Public reads use `/cred/items` and `/cred/items/ITEM_ID`.
|
||||
They do not return stored secret values.
|
||||
|
||||
### Approve a request
|
||||
|
||||
The agent or world UI obtains pending requests from `/harness/credential-requests` or `/cred/requests?state=pending`.
|
||||
Follow the approval link on the host origin through the tunnel.
|
||||
Review the stored summary and public details, choose limits when applicable, then approve or deny.
|
||||
The world can file a request. It cannot approve its own request.
|
||||
|
||||
Sources: [`host API`](../host/src/api/README.md), [`pages.rs`](../host/src/api/pages.rs),
|
||||
[`edits.rs`](../host/src/cred/edits.rs), and [`app.js`](../host/src/api/app.js).
|
||||
|
||||
## Models
|
||||
|
||||
The host requires an `llm-proxy` credential before the harness starts.
|
||||
On an unlocked new store, it can bootstrap that item from these host environment variables:
|
||||
|
||||
- `PI_PROXY_BASE_URL`: the proxy base URL, without the appended `/v1`.
|
||||
- `PI_PROXY_API_KEY`: the proxy API key.
|
||||
|
||||
Supply them through your host secret mechanism. Do not print them or commit them.
|
||||
Do not put either value in a world configuration file.
|
||||
Once the item exists, the host uses it rather than replacing it from the environment.
|
||||
The bootstrap is not a general credential import interface for a locked store.
|
||||
|
||||
World model families live at `/src/sausage/agents/models.json`.
|
||||
Agent definitions live in `/src/sausage/agents/*.md`.
|
||||
Project overlays live in `<repo>/.sausage/agents/`.
|
||||
The models extension reads these files at each run boundary. Edits affect the next run without a restart.
|
||||
An explicit model override can take precedence over a family.
|
||||
The initial console root has a host-defined model. Do not assume a family edit changes every existing conversation.
|
||||
All real model requests retain the host proxy transport.
|
||||
|
||||
Sources: [`credentials.rs`](../host/src/harness/credentials.rs), [`main.ts`](../host/js/main.ts),
|
||||
and [`models`](../extensions/models/README.md).
|
||||
|
||||
## Change extensions
|
||||
|
||||
The world checkout is `/src/sausage`. The loader follows its **`main`** branch, not the host checkout's `v3` branch.
|
||||
Each extension lives at `extensions/NAME/server.ts` inside that checkout.
|
||||
Commit extension edits to `main` with world git. A saved, uncommitted edit does not reload an extension.
|
||||
Use `--no-gpg-sign` for commits.
|
||||
|
||||
The loader imports a detached snapshot under `/run/sausage/extensions/COMMIT`.
|
||||
It validates the entire candidate before it replaces the live registry.
|
||||
A broken candidate leaves the previous good commit active.
|
||||
An active tool call retains its implementation. The next call uses the new implementation.
|
||||
|
||||
Read `/proc/extensions` through the agent's file tools to inspect `current`, `good`, and `error`.
|
||||
To restore the last good branch ref, ask the agent to execute:
|
||||
|
||||
```sh
|
||||
extensions-revert
|
||||
```
|
||||
|
||||
The command preserves editable files. It changes `refs/heads/main`, which triggers the normal reload.
|
||||
At startup, the loader tries the tip and then the last good commit before it resumes work.
|
||||
|
||||
Source: [`extensions`](../extensions/README.md) and [`extensions.ts`](../host/js/extensions.ts).
|
||||
|
||||
## Everyday tools inside the world
|
||||
|
||||
These are world programs, not console commands. Ask the agent to run them.
|
||||
|
||||
| Tool | Use and limits |
|
||||
| --- | --- |
|
||||
| `cargo` | Build, run, and test Rust programs. The host copies the workspace through the VFS, fetches dependencies, then builds offline in namespaces. It publishes WASI artifacts into the world. Other targets are unsupported. |
|
||||
| `box` | Build non-Rust projects or run browser tests in disposable containers. The default service is `citadel.vpn.surma.technology:8093`. It requires network access and service availability. Remove boxes after use. |
|
||||
| `undo` | Inspect tags and restore filesystem changes. Later edits can conflict. Network effects and native builds are not reversible. Retention is seven days or 4 GiB of unique content. |
|
||||
| `systemctl` | Control world init services, not Citadel's systemd. Persistent units live under `/etc/systemd/system`. Init observes edits without daemon reload. |
|
||||
| `journalctl` | Read world service logs under `/var/log/journal`. Each unit retains a 1 MiB log and one rotated file. |
|
||||
| `git` | Manage world repositories. HTTPS credential rules can inject authorization through the host HTTP hook. SSH remotes use host signing. |
|
||||
| `ssh` | Execute a remote command without a PTY. Public identities and signatures come from the host. Private keys never enter the world. Host keys live in the world's known_hosts file. |
|
||||
|
||||
Init starts when the host starts the runtime, if `/bin/init.wasm` exists.
|
||||
Transient units under `/run/systemd/transient` disappear after restart.
|
||||
A missing credential grant causes a pending host request, not an automatic approval.
|
||||
A changed SSH host key fails even with the accept-new policy.
|
||||
|
||||
Command reference and implementation evidence:
|
||||
[`builder`](../host/src/builder/README.md), [`box`](../userland/box/README.md),
|
||||
[`undo`](../userland/undo/README.md), [`init`](../userland/init/README.md),
|
||||
[`git`](../userland/git/README.md), and [`ssh`](../userland/ssh/README.md).
|
||||
|
||||
## Build a world UI with the host API
|
||||
|
||||
World programs call **`http://host.sausage`** through standard WASI HTTP.
|
||||
The listener on port 8081 does **not** expose harness routes.
|
||||
A browser UI needs a world server that forwards these requests.
|
||||
Keep credential approval and secret entry on host pages.
|
||||
|
||||
| Method and route | Result or request |
|
||||
| --- | --- |
|
||||
| `GET /harness/conversations` | Conversation records, titles, ownership, ancestry, and state. |
|
||||
| `GET /harness/conversations/ID/entries?after=0` | `{entries:[...]}` in ascending order. |
|
||||
| `GET /harness/conversations/ID/stream?after=0` | NDJSON entries, replay first, then live commits. |
|
||||
| `POST /harness/conversations/ID/submit` | `{content:"text",requestId:"stable-id"}` returns 202 and a submission ID. Reuse the request ID for retries. |
|
||||
| `POST /harness/conversations/ID/stop` | Stops active turns and owned work. Queued input remains. |
|
||||
| `GET /harness/events?after=0` | NDJSON events, replay first, then live commits. |
|
||||
| `POST /harness/extensions/NAME/actions/ACTION` | `{args:{...},conversationId?:number}` invokes an exported user action. |
|
||||
| `GET /harness/models` | Public model catalog without authentication data. |
|
||||
| `GET /harness/families` | World model families. |
|
||||
| `GET /harness/extensions` | Loaded extension status. |
|
||||
| `GET /harness/credential-requests` | Pending conversation requests with host approval links. |
|
||||
|
||||
The `after` cursor is exclusive. Reconnect with the last record ID.
|
||||
Streams close when the client disconnects or the harness stops.
|
||||
Do not render a stream as one JSON response.
|
||||
An unavailable harness returns 503. Invalid input returns 400. Missing resources return 404.
|
||||
|
||||
Source: [`host API contract`](../host/src/api/README.md),
|
||||
[`harness-api.ts`](../host/js/harness-api.ts), and [`harness/http.rs`](../host/src/harness/http.rs).
|
||||
|
||||
## Known gaps before cutover
|
||||
|
||||
- The Nix package wraps the host and builder tools but does not ship or select the default userland bundle.
|
||||
- The development bundle passes fresh-world startup checks. The Nix package start remains unverified until its bundle integration lands.
|
||||
- Laptop access, passkey ceremonies, secret entry, and browser approval remain **to check with Surma**.
|
||||
- Startup checks use fake proxy credentials. They do not verify a real model session or external credential injection.
|
||||
- A usable `llm-proxy` credential is mandatory even before the first chat.
|
||||
- The browser has approval, passkey, and secret-entry pages, but no item editor, dashboard, or chat UI.
|
||||
- The console has no shell commands, transcript navigation, or special quit command.
|
||||
- `sausage sh` requires `-c`. Its interactive shell is not implemented.
|
||||
- The flake's runtime-toolchain check and loop comments still describe v2 behavior.
|
||||
- This guide does not migrate a v2 world. Keep the live v2 folder untouched.
|
||||
|
||||
A successful development start does not verify the Nix package, external services, or the production passkey origin.
|
||||
@@ -34,7 +34,7 @@ export function lander(host:any,workspaces:any) {
|
||||
const remote=host.settings().lander?.push
|
||||
if(remote)await run(path,'fetch',remote,entry.target)
|
||||
if(remote)await run(path,'merge','--no-edit','FETCH_HEAD')
|
||||
await run(path,'merge','--no-ff','--no-edit','-m',`Merge from #${entry.conversationId} into ${entry.target}`,entry.tip)
|
||||
await run(path,'merge','--no-ff','--no-edit','-m',`Merge from #${entry.conversationId} into ${entry.target}\n\nSausage-Conversation: ${entry.conversationId}`,entry.tip)
|
||||
const sha=await run(path,'rev-parse','HEAD')
|
||||
for(const configured of host.settings().lander?.checks??[]) {
|
||||
const check=typeof configured==='string'?{command:configured}:configured
|
||||
|
||||
@@ -349,6 +349,15 @@ impl Runtime {
|
||||
.env
|
||||
.push(("PATH".into(), "/usr/local/bin:/bin".into()));
|
||||
}
|
||||
// Keep process attribution aligned with the nearest conversation, including children.
|
||||
if let Some(conversation) = conversations.first() {
|
||||
request
|
||||
.env
|
||||
.retain(|(name, _)| name != "SAUSAGE_CONVERSATION");
|
||||
request
|
||||
.env
|
||||
.push(("SAUSAGE_CONVERSATION".into(), conversation.clone()));
|
||||
}
|
||||
request.cwd = sausage_vfs::resolve("/", &request.cwd);
|
||||
request.env.retain(|(name, _)| name != "PWD");
|
||||
request.env.push(("PWD".into(), request.cwd.clone()));
|
||||
|
||||
@@ -42,6 +42,7 @@ impl World {
|
||||
}
|
||||
|
||||
/// Copy an optional bundle into a new world. Existing worlds retain their programs.
|
||||
/// Ensure `/tmp`, `/run`, and `/var/tmp` exist on every startup.
|
||||
/// Each `.wasm` tool keeps its independent file and gains a relative `/bin/<name>` symlink.
|
||||
/// `/dev` exposes generated null, zero, and random byte devices in every world.
|
||||
/// Missing `/etc/gitconfig` defaults to Sausage <sausage@localhost>. Local repository config overrides it.
|
||||
@@ -50,6 +51,9 @@ impl World {
|
||||
let host_dir = dir.join("host");
|
||||
let fresh = !root_dir.exists();
|
||||
std::fs::create_dir_all(root_dir.join("usr/local/bin"))?;
|
||||
for path in ["tmp", "run", "var/tmp"] {
|
||||
std::fs::create_dir_all(root_dir.join(path))?;
|
||||
}
|
||||
if fresh && let Some(bundle) = userland {
|
||||
for folder in ["bin", "src"] {
|
||||
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
|
||||
|
||||
@@ -125,6 +125,37 @@ async fn run(fixture: &Fixture, script: &str) -> String {
|
||||
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output));
|
||||
String::from_utf8(output).unwrap()
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn conversation_bash_commit_inherits_nearest_attribution() {
|
||||
let fixture = fixture();
|
||||
let mut options = options();
|
||||
options.conversations = vec!["158".into(), "79".into()];
|
||||
options
|
||||
.env
|
||||
.push(("SAUSAGE_CONVERSATION".into(), "wrong".into()));
|
||||
let mut output = Vec::new();
|
||||
let code = fixture
|
||||
.runtime
|
||||
.exec(
|
||||
Command::Shell("sh -c 'git commit --allow-empty --no-gpg-sign -m attributed'".into()),
|
||||
options,
|
||||
&mut |_, bytes| output.extend_from_slice(bytes),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output));
|
||||
let commit = std::process::Command::new("git")
|
||||
.args(["log", "worker", "-1", "--format=%B"])
|
||||
.current_dir(fixture._temp.path().join("world/src/demo"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(commit.status.success());
|
||||
let text = String::from_utf8(commit.stdout).unwrap();
|
||||
assert!(text.contains("Sausage-Conversation: 158"), "{text}");
|
||||
assert!(!text.contains("Sausage-Conversation: 79"), "{text}");
|
||||
assert!(!text.contains("Sausage-Conversation: wrong"), "{text}");
|
||||
}
|
||||
|
||||
async fn edit(fixture: &Fixture) {
|
||||
let file = fixture
|
||||
.root
|
||||
|
||||
+89
-6
@@ -1,14 +1,15 @@
|
||||
//! P1 integration: independent bundle copies and real shell calls from the durable harness.
|
||||
//! P1 integration: bundle contents, source commits, extension loading, and real shell calls.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use sausage_vfs::Open;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
fn bundle() -> anyhow::Result<PathBuf> {
|
||||
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let bundle = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()).join("bundle");
|
||||
if !bundle.join("bin/sh.wasm").exists() {
|
||||
let _guard = crate::common::BUILD.lock().unwrap();
|
||||
let bundle = crate::common::target_dir().join("bundle");
|
||||
let _guard = crate::common::BUILD.lock().unwrap();
|
||||
if !bundle.join("bin/sh.wasm").exists()
|
||||
|| !bundle.join("src/sausage/agents/models.json").exists()
|
||||
{
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.arg(repo.join("userland/bundle.nu"))
|
||||
@@ -17,9 +18,47 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
.success()
|
||||
);
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let bundle = bundle()?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
|
||||
assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0);
|
||||
let snapshot = dir.path().join("world/src/sausage");
|
||||
for path in [
|
||||
"agents/models.json",
|
||||
"agents/ultimate.md",
|
||||
"skills/README.md",
|
||||
"extensions/README.md",
|
||||
] {
|
||||
assert!(snapshot.join(path).is_file(), "missing bundled {path}");
|
||||
}
|
||||
let git = |path: &std::path::Path, args: &[&str]| -> anyhow::Result<String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(args)
|
||||
.output()?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
|
||||
};
|
||||
let message = git(&snapshot, &["log", "-1", "--format=%s"])?;
|
||||
let revision = message.strip_prefix("Bundle source ").unwrap();
|
||||
for path in ["agents", "skills", "extensions"] {
|
||||
assert_eq!(
|
||||
git(&snapshot, &["rev-parse", &format!("HEAD:{path}")])?,
|
||||
git(&repo, &["rev-parse", &format!("{revision}:{path}")])?,
|
||||
"bundled {path} must match the source commit"
|
||||
);
|
||||
}
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
for entry in std::fs::read_dir(dir.path().join("world/bin"))? {
|
||||
let entry = entry?;
|
||||
@@ -243,3 +282,47 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
assert!(world.root.stat("/bin/sh", true).await.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bundled_extensions_load() -> anyhow::Result<()> {
|
||||
let bundle = bundle()?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
|
||||
let root = world.root.clone();
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
// Use the compiled production loader without the console entrypoint or model proxy.
|
||||
let (library, _) = include_str!("../../js/bundle.js")
|
||||
.split_once("// main.ts")
|
||||
.expect("console bundle entrypoint");
|
||||
let source = format!(
|
||||
r#"{library}
|
||||
const bundleModels = createModels();
|
||||
const bundleRegistry = createRegistry();
|
||||
bundleRegistry.install(CodingTools2);
|
||||
bundleRegistry.install(CredentialRequests);
|
||||
const bundleStorage = await openStorage(globalThis.storagePath);
|
||||
const bundleHarness = await Harness.open(bundleStorage, {{
|
||||
models: bundleModels, registry: bundleRegistry,
|
||||
settings: {{ get extensions() {{ return bundleRegistry.snapshot().installed(); }} }}
|
||||
}}, BACKGROUND_CONTEXT);
|
||||
await bundleHarness.root(BACKGROUND_CONTEXT);
|
||||
const bundleLoader = await loadExtensions(bundleHarness, bundleRegistry, bundleStorage,
|
||||
bundleModels, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]);
|
||||
if (bundleLoader.state().error) throw new Error(bundleLoader.state().error);
|
||||
await bundleLoader.close();
|
||||
await bundleHarness.close(BACKGROUND_CONTEXT);
|
||||
"#
|
||||
);
|
||||
run_module(world, exec, &source).await?;
|
||||
let status = root.open("/proc/extensions", Open::default()).await?;
|
||||
let status: serde_json::Value = serde_json::from_slice(&status.read(0, 65536).await?)?;
|
||||
let commit =
|
||||
std::fs::read_to_string(dir.path().join("world/src/sausage/.git/refs/heads/main"))?;
|
||||
assert_eq!(status["good"].as_str(), Some(commit.trim()), "{status}");
|
||||
assert!(status["error"].is_null(), "{status}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -57,6 +57,24 @@ fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_restores_temporary_directories_on_startup() -> anyhow::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
for restart in [false, true] {
|
||||
let world = World::open(temp.path())?;
|
||||
for path in ["tmp", "run", "var/tmp"] {
|
||||
let directory = temp.path().join("world").join(path);
|
||||
assert!(
|
||||
directory.is_dir(),
|
||||
"missing /{path} after restart={restart}"
|
||||
);
|
||||
std::fs::remove_dir(&directory)?;
|
||||
}
|
||||
drop(world);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod features {
|
||||
//! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases.
|
||||
use super::*;
|
||||
|
||||
@@ -207,7 +207,29 @@ async fn coordination() -> anyhow::Result<()> {
|
||||
World::open(dir.path())?;
|
||||
prepare(dir.path())?;
|
||||
let _server = serve(dir.path())?;
|
||||
run(dir.path(), "normal").await
|
||||
run(dir.path(), "normal").await?;
|
||||
let output = Command::new("git")
|
||||
.args(["log", "-1", "--format=%s%n%B"])
|
||||
.current_dir(dir.path().join("world/project"))
|
||||
.output()?;
|
||||
assert!(output.status.success());
|
||||
let message = String::from_utf8(output.stdout)?;
|
||||
let submitter = message
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.strip_prefix("Merge from #")
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
message
|
||||
.lines()
|
||||
.any(|line| line == format!("Sausage-Conversation: {submitter}")),
|
||||
"{message}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn restart_child() -> anyhow::Result<()> {
|
||||
|
||||
+2
-2
@@ -41,14 +41,14 @@ def main [destination?: path] {
|
||||
if ($snapshot | path exists) { rm --recursive --force $snapshot }
|
||||
mkdir $snapshot
|
||||
let archive = ($destination | path join source.tar)
|
||||
^git -C $repo archive --output $archive HEAD host vfs userland extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore
|
||||
^git -C $repo archive --output $archive HEAD host vfs userland agents skills extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore
|
||||
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source archive failed'} }
|
||||
^tar -xf $archive -C $snapshot
|
||||
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source extraction failed'} }
|
||||
rm $archive
|
||||
^git -C $snapshot init --quiet --initial-branch=main
|
||||
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git init failed'} }
|
||||
^git -C $snapshot add host vfs userland extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore
|
||||
^git -C $snapshot add host vfs userland agents skills extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore
|
||||
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git add failed'} }
|
||||
with-env {
|
||||
GIT_AUTHOR_NAME: 'Sausage', GIT_AUTHOR_EMAIL: 'sausage@localhost',
|
||||
|
||||
Reference in New Issue
Block a user