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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions MANIFEST
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
/etc/resolv.conf f 0644 0 0
/etc/nsswitch.conf f 0644 0 0
/etc/os-release f 0644 0 0
/etc/profile.d d 0755 0 0
/etc/profile.d/kandelo-prompt.sh f 0644 0 0
/etc/profile f 0644 0 0
/etc/motd f 0644 0 0
# The rootfs file is the authoritative services database. Rootfs-derived
Expand Down
2 changes: 0 additions & 2 deletions apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Builds a LiveKernelHost over a real BrowserKernel for the Kandelo page.

import { BrowserKernel } from "@host/browser-kernel-host";

Check failure on line 3 in apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts

View workflow job for this annotation

GitHub Actions / test-gate-validation

tests/package-system/rootfs-verified-source-contract.test.ts > source-rootfs verified archive contract > keeps browser shell launch policy without overriding the image prompt

Error: Cannot find package '@host/browser-kernel-host' imported from /home/runner/work/kandelo/kandelo/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts ❯ apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts:3:1 ❯ tests/package-system/rootfs-verified-source-contract.test.ts:409:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }
import { ensureServiceWorkerReady } from "../../../lib/init/service-worker-bridge";
import { setupServiceWorkerFetchBridge } from "../../../lib/init/sw-bridge-fetch";
import { bindImageOwnedRuntimeUrls } from "../../../lib/init/image-owned-runtime-urls";
Expand Down Expand Up @@ -519,7 +519,6 @@
"PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin",
`USER=${DEMO_USER}`,
`LOGNAME=${DEMO_USER}`,
"PS1=kandelo$ ",
`HISTFILE=${DEMO_HOME}/.bash_history`,
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_DIR=/etc/ssl/certs",
Expand All @@ -534,7 +533,6 @@
"PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin",
`USER=${DEMO_USER}`,
`LOGNAME=${DEMO_USER}`,
"PS1=spidermonkey-node$ ",
`HISTFILE=${NODE_WORKDIR}/.bash_history`,
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_DIR=/etc/ssl/certs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ async function bootExactShellPage(page: Page): Promise<ExactShellPage> {
await expect(page.locator(".xterm-rows").first()).toBeVisible({
timeout: 180_000,
});
await waitForTerminalContent(page, /kandelo\$\s*$/, 240_000);
await waitForTerminalContent(page, /user@kandelo ~ ❯\s*$/, 240_000);
const mirrorPlan = await page.evaluate(async (url) => {
const response = await fetch(url, {
cache: "no-store",
Expand Down
4 changes: 2 additions & 2 deletions apps/browser-demos/test/kandelo-homebrew.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ test("Homebrew file-formula VFS image boots in browser and runs file --version",

await gotoOrSkip(page, `/?vfs=${encodeURIComponent(vfsUrl!)}`, false);
await expect(page.locator(".xterm-rows").first()).toBeVisible({ timeout: 120_000 });
await waitForTerminalContent(page, /kandelo\$\s*$/, 240_000);
await waitForTerminalContent(page, /(?:^|\n)[^\n]*[$#]\s*$/, 240_000);

await runTerminalCommand(
page,
Expand Down Expand Up @@ -235,7 +235,7 @@ test("an image-owned Homebrew shell boots without legacy shell downloads", async

await gotoOrSkip(page, `/?vfs=${encodeURIComponent(vfsUrl)}`, false);
await expect(page.locator(".xterm-rows").first()).toBeVisible({ timeout: 120_000 });
await waitForTerminalContent(page, /kandelo\$\s*$/, 180_000);
await waitForTerminalContent(page, /(?:^|\n)[^\n]*[$#]\s*$/, 180_000);
// This assertion must observe the image-selected interactive shell itself,
// not the isolated Bash process used for ordinary behavioral commands.
await runParentShellProbe(
Expand Down
28 changes: 25 additions & 3 deletions apps/browser-demos/test/kandelo-merge-gate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ async function waitForTerminalContent(
}
}

async function runTerminalLine(page: Page, command: string) {
const terminalInput = page
.getByRole("textbox", { name: "Terminal input" })
.first();
if (await terminalInput.count()) {
await terminalInput.focus();
} else {
await page.locator(".kshell-host").first().click();
}
await page.keyboard.insertText(command);
await page.waitForTimeout(250);
await page.keyboard.press("Enter");
}

async function runGuideScript(
page: Page,
script: string,
Expand Down Expand Up @@ -235,6 +249,14 @@ test("Kandelo shell demo runs bash, vim, and NetHack", async ({ page }) => {
await gotoOrSkip(page, "/?demo=shell");
await waitForReady(page);
await expect(page.locator(".xterm-rows").first()).toBeVisible({ timeout: 120_000 });
await waitForTerminalContent(page, /user@kandelo ~ ❯\s*$/, 120_000);
await runTerminalLine(page, "cd /tmp");
await waitForTerminalContent(page, /user@kandelo \/tmp ❯\s*$/, 120_000);
await runGuideScript(
page,
"printf 'KANDELO_PROMPT_CWD_OK:%s\\n' \"$PWD\"",
"KANDELO_PROMPT_CWD_OK:/tmp",
);

await runGuideScript(
page,
Expand All @@ -244,7 +266,7 @@ test("Kandelo shell demo runs bash, vim, and NetHack", async ({ page }) => {
"else\n" +
" printf 'KANDELO_BASH_FAIL:%s\\n' \"$PWD\"\n" +
"fi",
/KANDELO_BASH_OK:[0-9][^\r\n]*:\/home\/user/,
/KANDELO_BASH_OK:[0-9][^\r\n]*:\/tmp/,
);
await runGuideScript(
page,
Expand Down Expand Up @@ -352,7 +374,7 @@ test("Kandelo nginx demo serves its web preview", async ({ page }) => {
);

await openTerminalDrawer(page);
await waitForTerminalContent(page, /kandelo\$ ?/, 120_000);
await waitForTerminalContent(page, /user@kandelo ~ ❯\s*$/, 120_000);
await runTerminalCommand(
page,
"set -eu; test \"$(id -u):$HOME:$(pwd)\" = '1000:/home/user:/home/user'; " +
Expand Down Expand Up @@ -385,7 +407,7 @@ test("Kandelo nginx + PHP demo serves dynamic PHP through the web preview", asyn
);

await openTerminalDrawer(page);
await waitForTerminalContent(page, /kandelo\$ ?/, 120_000);
await waitForTerminalContent(page, /user@kandelo ~ ❯\s*$/, 120_000);
await runTerminalCommand(
page,
"set -eu; test \"$(id -u):$HOME:$(pwd)\" = '1000:/home/user:/home/user'; " +
Expand Down
26 changes: 25 additions & 1 deletion apps/browser-demos/test/kandelo-source-rootfs-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ async function terminalText(page: Page): Promise<string> {
);
}

async function runTerminalLine(page: Page, command: string) {
const terminalInput = page
.getByRole("textbox", { name: "Terminal input" })
.first();
if (await terminalInput.count()) {
await terminalInput.focus();
} else {
await page.locator(".kshell-host").first().click();
}
await page.keyboard.insertText(command);
await page.waitForTimeout(250);
await page.keyboard.press("Enter");
}

async function runGuideScript(
page: Page,
script: string,
Expand Down Expand Up @@ -66,6 +80,16 @@ test("the exact source-rootfs product shell runs Bash, Vim, and NetHack", async
await expect(page.locator(".xterm-rows").first()).toBeVisible({
timeout: 120_000,
});
await expect.poll(() => terminalText(page), { timeout: 120_000 })
.toMatch(/user@kandelo ~ ❯\s*$/);
await runTerminalLine(page, "cd /tmp");
await expect.poll(() => terminalText(page), { timeout: 120_000 })
.toMatch(/user@kandelo \/tmp ❯\s*$/);
await runGuideScript(
page,
"printf 'KANDELO_PROMPT_CWD_OK:%s\\n' \"$PWD\"",
"KANDELO_PROMPT_CWD_OK:/tmp",
);

await runGuideScript(
page,
Expand All @@ -75,7 +99,7 @@ test("the exact source-rootfs product shell runs Bash, Vim, and NetHack", async
"else\n" +
" printf 'SOURCE_ROOTFS_BASH_FAIL:%s\\n' \"$PWD\"\n" +
"fi",
/SOURCE_ROOTFS_BASH_OK:[0-9][^\r\n]*:\/home\/user/,
/SOURCE_ROOTFS_BASH_OK:[0-9][^\r\n]*:\/tmp/,
);
await runGuideScript(
page,
Expand Down
5 changes: 3 additions & 2 deletions apps/browser-demos/test/kandelo-webkit-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,17 @@ async function waitForReady(page: Page, timeout = 180_000) {
async function waitForPrompt(page: Page, timeout = 120_000) {
await expect
.poll(() => terminalText(page), { timeout })
.toContain("kandelo$");
.toMatch(/user@kandelo ~ ❯\s*$/);
}

async function runTerminalLine(page: Page, command: string) {
// WHY: this smoke intentionally tests raw WebKit input plus a persistent
// parent-shell prompt; callers split success tokens so echo cannot match.
await page.locator(".kshell-host").first().click();
const terminalInput = page.getByRole("textbox", { name: "Terminal input" }).first();
if (await terminalInput.count()) {
await terminalInput.focus();
} else {
await page.locator(".kshell-host").first().click();
}
await page.keyboard.insertText(command);
await page.waitForTimeout(250);
Expand Down
5 changes: 3 additions & 2 deletions crates/kernel/src/syscalls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13758,7 +13758,7 @@ pub fn sys_uname(buf: &mut [u8]) -> Result<(), Errno> {
}
let fields: [&[u8]; 6] = [
b"wasm-posix", // sysname
b"localhost", // nodename
b"kandelo", // nodename
b"1.0.0", // release
b"kandelo", // version
b"wasm32", // machine
Expand Down Expand Up @@ -24790,7 +24790,8 @@ mod tests {
assert_eq!(&buf[0..10], b"wasm-posix");
assert_eq!(buf[10], 0); // null terminated
// nodename at offset 65
assert_eq!(&buf[65..74], b"localhost");
assert_eq!(&buf[65..72], b"kandelo");
assert_eq!(buf[72], 0); // null terminated
// machine at offset 260
assert_eq!(&buf[260..266], b"wasm32");
}
Expand Down
14 changes: 14 additions & 0 deletions docs/browser-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,20 @@ readable `/etc/profile.d/*.sh` fragments there, so an image composer can add
package-manager environment setup without teaching the browser about a
particular package or prefix.

The default rootfs supplies `/etc/profile.d/kandelo-prompt.sh` for interactive
Bash login shells. Its one-line prompt uses Bash's real account (`\u`), kernel
hostname (`\h`), and working directory (`\w`) as
`user@kandelo ~/path ❯`. The identity, path, and privilege-aware glyph are
colored on ordinary terminals; `TERM=dumb` receives an unstyled `$` or `#`
form. The fragment does not run subprocesses while rendering and does not
change Dash or noninteractive shells.

The styled Bash prompt emits OSC 133 prompt-start (`A`) and command-start (`B`)
markers around its visible text. The browser waits for the trailing `B`
boundary when it sends guided commands, so a changing directory or account
does not require a hardcoded visible `PS1`. Image-selected custom shells retain
exact-prompt or conservative `$`/`#` readiness and keep their own presentation.

`terminal.run` sends a command through the persistent PTY-backed shell.
`terminal.write` sends raw text to that PTY, which is useful for entering input
into an already-running REPL. `guide.companion.srcDoc` runs in a sandboxed
Expand Down
4 changes: 2 additions & 2 deletions docs/posix-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ same final-OFD lifetime rules.
| `syslog()` | Partial | `SYS_SYSLOG` (kernel-log control) returns 0. Kandelo does not currently provide a `/dev/log` datagram receiver; AF_UNIX datagram connect therefore exposes the missing endpoint instead of silently discarding messages. |
| `capget()` / `capset()` | Stub | Returns EPERM. No capabilities model. |
| `vhangup()` | Stub | Returns EPERM. |
| `sethostname()` / `setdomainname()` | Stub | Returns EPERM. |
| `sethostname()` / `setdomainname()` | Stub | Returns EPERM. Kandelo's default nodename is currently fixed rather than mutable per machine. |
| `init_module()` / `delete_module()` | Stub | Returns EPERM. No kernel module support. |
| `ioperm()` / `iopl()` | Stub | Returns EPERM. No I/O port access. |
| `remap_file_pages()` | Stub | Returns ENOSYS. |
Expand Down Expand Up @@ -407,7 +407,7 @@ All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, determinist

| Function | Status | Notes |
|----------|--------|-------|
| `uname()` | Full | Returns sysname="wasm-posix", nodename="localhost", release="1.0.0", version="kandelo", machine="wasm32". 5 x 65-byte null-terminated strings. |
| `uname()` / `gethostname()` | Full | `uname()` returns sysname="wasm-posix", nodename="kandelo", release="1.0.0", version="kandelo", and machine="wasm32" in 65-byte null-terminated fields. musl's `gethostname()` derives the same fixed default nodename from `uname()`. |
| `sysconf()` | Partial | Handles _SC_CHILD_MAX, _SC_CLK_TCK=100, _SC_PAGE_SIZE=65536, _SC_OPEN_MAX=1024, _SC_NPROCESSORS_ONLN=1, _SC_NPROCESSORS_CONF=1, _SC_MONOTONIC_CLOCK=1, _SC_THREAD_SAFE_FUNCTIONS=1, plus 100+ POSIX.1-2024 constants via musl overlay. Unknown names return EINVAL. |
| `umask()` | Full | Set file creation mask, returns previous mask. Default 0o022. Applied in open() and mkdir(). Masked to 0o777. |
| `getrlimit()` | Full | Returns (soft, hard) resource limits. Defaults: NOFILE=(1024,4096), STACK=(8MB,infinity), others infinity. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Kandelo Default Bash Prompt Design

## Why

Kandelo's default browser shell currently presents the fixed prompt
`kandelo$ `. It does not identify the active account or working directory,
which makes shell state harder to read and will become misleading when real
login sessions can select different users.

The surrounding system identity is also inconsistent. The kernel reports
`localhost` as the `uname(2)` nodename, while `/etc/hostname` contains
`wasm-posix`. Hardcoding `kandelo` in a prompt shaped like `user@host` would
add a third value and make presentation look more correct than the underlying
platform state.

The new default should be attractive but restrained, derive its identity from
real process and kernel state, work through normal login-shell startup, and
preserve Kandelo's reliable PTY-backed command automation.

## User-visible behavior

An interactive Kandelo Bash login shell displays a compact one-line prompt:

```text
user@kandelo ~/src/project ❯
```

- The account name comes from Bash's `\u` prompt escape, which resolves the
current account rather than trusting the possibly stale `$USER` environment
variable.
- The host name comes from Bash's `\h` prompt escape and therefore from
Kandelo's `gethostname`/`uname` state.
- The working directory comes from `\w`, including Bash's normal `$HOME` to
`~` abbreviation.
- The identity, path, and prompt glyph use restrained ANSI colors when the
terminal supports them. The root prompt glyph is red; an ordinary user's is
green.
- `TERM=dumb` receives the same identity and path without terminal styling and
uses Bash's conventional privilege-aware `$`/`#` glyph.

The default does not run Git, `id`, or any other subprocess while drawing a
prompt. It does not add a clock, branch name, or success decoration. Those
features add latency and noise to a WebAssembly shell and can trigger package
materialization merely by showing an idle prompt.

## Authoritative system identity

Kandelo's default kernel nodename changes from `localhost` to `kandelo`.
`images/rootfs/etc/hostname` changes from `wasm-posix` to the same value.
`uname`, `gethostname`, the rootfs file, and Bash's `\h` consequently agree.

This work changes the default hostname value, not the syscall interface:
`uname` keeps its existing layout and behavior, and `sethostname` remains an
honest `EPERM` stub. The change therefore does not require an ABI version bump
or structural ABI snapshot update. Future mutable or per-machine hostnames
will require a separate design for authoritative UTS state and
`sethostname` semantics.

## Shell configuration ownership

The visible prompt belongs to the guest image, not the browser loader. A
tracked `/etc/profile.d` fragment supplies the default for interactive Bash
login shells, and `/etc/profile` continues to source readable fragments
through its existing POSIX-shell path.

The fragment is guarded by both Bash identity and interactive-shell state so
Dash, service processes, and noninteractive login commands do not receive
Bash-only prompt escapes. Rootfs and rootfs-derived shell images carry the
same tracked fragment through the ordinary manifest and VFS composition path.
Package revisions and derived image inputs are updated wherever these changed
bytes invalidate a published artifact.

The browser's hardcoded visible `PS1` values stop being the authority for Bash
sessions. Consequently, every Bash login shell using the Kandelo rootfs,
including specialized demo sessions, follows the same real user, hostname,
and working-directory convention. A custom image that selects another shell
retains that shell's own prompt behavior.

## PTY readiness protocol

Kandelo currently recognizes the exact browser-supplied `PS1` string to decide
when a persistent shell has completed a guided command. A prompt containing
`\u`, `\h`, or `\w` cannot be compared as one fixed visible string, and
falling back to a trailing `$ ` heuristic can mistake ordinary command output
for readiness.

The Bash prompt therefore brackets its visible text with the standard
invisible OSC 133 prompt-start (`A`) and command-start (`B`) markers.
`LiveKernelHost` recognizes the trailing `B` marker—the boundary after prompt
rendering and immediately before input—as the readiness signal. The terminal
renders only the dynamic prompt. Readiness remains separate from presentation:
changing directory, changing users through a real login, or restyling visible
colors does not change the machine-readable boundary.

Existing exact-prompt and conservative fallback detection remain available
for custom shells that do not emit the marker. The marker is not emitted for
`TERM=dumb`; its conventional `$`/`#` ending remains compatible with the
existing conservative fallback. Marker-based Bash detection waits for the
trailing `B` boundary and does not treat the leading `A` marker, shell
continuation prompts, or output that merely ends in a prompt-like glyph as
completion.

## Failure behavior

- If account lookup fails, Bash's native prompt expansion owns the visible
fallback; browser code does not synthesize a username.
- If a custom image does not include the prompt fragment, its shell remains
usable through the existing readiness paths.
- If a terminal does not interpret ANSI/OSC controls, `TERM=dumb` selects the
unstyled `$`/`#` prompt and no hidden control marker.
- A missing or stale rootfs artifact remains an artifact/build failure. The
browser does not patch `/etc/profile`, `/etc/hostname`, or the prompt at
runtime to disguise it.

## Documentation and validation

Implementation updates the current browser and POSIX documentation to state
that the default hostname is `kandelo`, explain the image-owned Bash prompt,
and distinguish the fixed default nodename from the still-unsupported mutable
hostname operation.

Validation covers the exact claims made by the change:

1. Kernel unit coverage verifies that `uname` returns `kandelo` as its
nodename without changing the surrounding struct fields.
2. Rootfs and shell-image tests verify `/etc/hostname`, the tracked prompt
fragment, manifest ownership, and derived-image preservation.
3. Session tests verify OSC-marker readiness across changing directories and
ensure prompt-looking command output and Bash continuation prompts do not
complete commands early.
4. Node-side shell/image validation verifies the composed guest state through
the normal VFS path.
5. Browser tests and a manual `./run.sh browser` check verify the rendered
`user@kandelo` prompt, `~`/working-directory updates, colors, and guided
command completion in the real terminal.
6. The ABI snapshot check verifies that no structural ABI surface changed.
Relevant `uname`/`gethostname` conformance coverage is considered because
the kernel's observable system identity changed.

Performance is not claimed. Avoiding per-prompt subprocesses is a design
constraint, not benchmark evidence.
2 changes: 1 addition & 1 deletion homebrew/source-rootfs-shell-package/build.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ inputs = [
]
repo_url = "https://github.com/Automattic/kandelo.git"
commit = "UNPUBLISHED"
revision = 3
revision = 4

[binary]
index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml"
2 changes: 1 addition & 1 deletion images/rootfs/etc/hostname
Original file line number Diff line number Diff line change
@@ -1 +1 @@
wasm-posix
kandelo
Loading
Loading