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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/core/Filesystem.eph
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,19 @@ fn listDir(path: String, capToken: I64): I64 =
// FFI: gossamer_fs_remove(path, cap_token) -> result
fn remove(path: String, capToken: I64): I32 =
__ffi("gossamer_fs_remove", path, capToken)

// Create a directory and any missing parent directories (mkdir -p).
// Idempotent — returns 0 when the directory already exists.
// Requires a valid filesystem capability token with write scope.
// Returns 0 (ok) or non-zero error code.
// FFI: gossamer_fs_mkdir_p(path, cap_token) -> result
fn mkdirP(path: String, capToken: I64): I32 =
__ffi("gossamer_fs_mkdir_p", path, capToken)

// Copy a file from src to dst, overwriting dst if it exists. The parent
// directory of dst must already exist — call mkdirP first if needed.
// Requires a valid filesystem capability token with write scope.
// Returns 0 (ok) or non-zero error code.
// FFI: gossamer_fs_copy_file(src, dst, cap_token) -> result
fn copyFile(src: String, dst: String, capToken: I64): I32 =
__ffi("gossamer_fs_copy_file", src, dst, capToken)
18 changes: 18 additions & 0 deletions src/core/ShellExec.eph
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,21 @@ fn execute(program: String, argsJson: String, capToken: I64): I64 =
// FFI: gossamer_shell_open(url, cap_token) -> result
fn openUrl(url: String, capToken: I64): I32 =
__ffi("gossamer_shell_open", url, capToken)

// Spawn a shell command in the background and return an opaque child
// handle. The command runs via /bin/sh -c (POSIX) or cmd /c (Windows)
// with stdin/stdout/stderr inherited from the caller. The handle is
// only valid until passed to spawnKill; do not use it for anything else.
// Requires a valid shell capability token.
// Returns 0 on error (check Shell.lastError for details).
// FFI: gossamer_shell_spawn(command, cap_token) -> opaque_handle
fn spawn(command: String, capToken: I64): I64 =
__ffi("gossamer_shell_spawn", command, capToken)

// Terminate a previously-spawned background child and wait for it to
// exit. Idempotent on 0 (no-op). The handle is invalid after this call.
// Requires a valid shell capability token.
// Returns 0 (ok) or non-zero error code.
// FFI: gossamer_shell_kill(opaque_handle, cap_token) -> result
fn spawnKill(handle: I64, capToken: I64): I32 =
__ffi("gossamer_shell_kill", handle, capToken)
67 changes: 67 additions & 0 deletions src/interface/ffi/src/filesystem.zig
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,70 @@ export fn gossamer_fs_remove(
main.clearError();
return .ok;
}

/// Create a directory recursively (equivalent of `mkdir -p`).
/// Succeeds when the directory already exists.
///
/// Validates the capability token is active and of type FileSystem (kind=0).
export fn gossamer_fs_mkdir_p(
path: [*:0]const u8,
cap_token: u64,
) main.Result {
if (main.gossamer_cap_check(cap_token) != .ok) {
main.setError("FileSystem capability denied");
return .capability_denied;
}
if (main.gossamer_cap_resource_kind(cap_token) != 0) {
main.setError("Wrong capability kind — expected FileSystem (0)");
return .capability_denied;
}

const path_slice = std.mem.span(path);

std.fs.cwd().makePath(path_slice) catch |e| {
switch (e) {
error.PathAlreadyExists => {
main.clearError();
return .ok;
},
else => {
main.setError("Failed to create directory");
return .@"error";
},
}
};

main.clearError();
return .ok;
}

/// Copy a file from `src` to `dst`. Overwrites the destination if it
/// exists. Parent directory of `dst` must already exist — call
/// gossamer_fs_mkdir_p first if needed.
///
/// Validates the capability token is active and of type FileSystem (kind=0).
export fn gossamer_fs_copy_file(
src: [*:0]const u8,
dst: [*:0]const u8,
cap_token: u64,
) main.Result {
if (main.gossamer_cap_check(cap_token) != .ok) {
main.setError("FileSystem capability denied");
return .capability_denied;
}
if (main.gossamer_cap_resource_kind(cap_token) != 0) {
main.setError("Wrong capability kind — expected FileSystem (0)");
return .capability_denied;
}

const src_slice = std.mem.span(src);
const dst_slice = std.mem.span(dst);

std.fs.cwd().copyFile(src_slice, std.fs.cwd(), dst_slice, .{}) catch {
main.setError("Failed to copy file");
return .@"error";
};

main.clearError();
return .ok;
}
7 changes: 7 additions & 0 deletions src/interface/ffi/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ comptime {
_ = @import("file_watcher.zig");
}

// Shell FFI functions (gossamer_shell_spawn, gossamer_shell_kill).
// Capability-gated background process management. Used by `gossamer dev`
// to run the user's frontend dev server alongside the webview.
comptime {
_ = @import("shell.zig");
}

// Version information — bump on each release
const VERSION = "0.3.0";
const BUILD_INFO = "Gossamer " ++ VERSION ++ " built with Zig " ++ @import("builtin").zig_version_string;
Expand Down
120 changes: 120 additions & 0 deletions src/interface/ffi/src/shell.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Gossamer — Shell FFI Implementation
//
// Provides shell command execution gated by capability tokens. Each
// operation validates the caller holds a Shell capability (kind=2)
// before invoking the host shell.
//
// Two-operation surface:
// • gossamer_shell_spawn — start a process in the background, return
// an opaque child handle. Stdin/stdout/stderr inherit from the
// caller. Used by `gossamer dev` to launch the user's frontend dev
// server (e.g. `deno task dev`) and keep it running alongside the
// webview.
// • gossamer_shell_kill — send SIGTERM (or platform equivalent) to
// a previously-spawned child, then wait for it. Idempotent on null.
//
// These functions are called from the IPC bridge or directly from
// Ephapax via the FFI shim in src/core/ShellExec.eph. Synchronous
// `gossamer_shell_execute` (run-to-completion, capture stdout) is a
// separate concern tracked elsewhere.
//
// SPDX-License-Identifier: PMPL-1.0-or-later
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>

const std = @import("std");
const main = @import("main.zig");

/// Heap-allocated wrapper around std.process.Child so the C ABI can
/// hand back a single opaque pointer. Freed inside gossamer_shell_kill
/// after waiting on the child.
const SpawnedChild = struct {
child: std.process.Child,
};

/// Spawn a shell command in the background. Stdin/stdout/stderr inherit
/// from the caller. The command is executed via `/bin/sh -c` on POSIX
/// hosts and via `cmd /c` on Windows so shell metacharacters resolve as
/// the user expects.
///
/// Validates the capability token is active and of type Shell (kind=2).
///
/// Returns an opaque handle suitable for gossamer_shell_kill, or null
/// on failure (check gossamer_last_error for details).
export fn gossamer_shell_spawn(
command: [*:0]const u8,
cap_token: u64,
) ?*anyopaque {
if (main.gossamer_cap_check(cap_token) != .ok) {
main.setError("Shell capability denied — call gossamer_cap_grant(2) first");
return null;
}
if (main.gossamer_cap_resource_kind(cap_token) != 2) {
main.setError("Wrong capability kind — expected Shell (2)");
return null;
}

const cmd_slice = std.mem.span(command);

const allocator = std.heap.c_allocator;
const wrapper = allocator.create(SpawnedChild) catch {
main.setError("Failed to allocate child wrapper");
return null;
};

const argv = switch (@import("builtin").os.tag) {
.windows => &[_][]const u8{ "cmd", "/c", cmd_slice },
else => &[_][]const u8{ "/bin/sh", "-c", cmd_slice },
};

wrapper.child = std.process.Child.init(argv, allocator);
wrapper.child.stdin_behavior = .Inherit;
wrapper.child.stdout_behavior = .Inherit;
wrapper.child.stderr_behavior = .Inherit;

wrapper.child.spawn() catch {
allocator.destroy(wrapper);
main.setError("Failed to spawn shell process");
return null;
};

main.clearError();
return @ptrCast(wrapper);
}

/// Terminate a previously-spawned child and wait for it. Sends SIGTERM
/// on POSIX hosts; on Windows the std.process.Child.kill() implementation
/// uses TerminateProcess. Blocks until the child exits. Idempotent on
/// null (no-op). Frees the wrapper after waiting; the opaque handle is
/// invalid after this call returns.
///
/// Validates the capability token is active and of type Shell (kind=2).
/// Returns Result (0=ok, 1=error, 10=capability_denied).
export fn gossamer_shell_kill(
opaque_handle: ?*anyopaque,
cap_token: u64,
) main.Result {
if (main.gossamer_cap_check(cap_token) != .ok) {
main.setError("Shell capability denied");
return .capability_denied;
}
if (main.gossamer_cap_resource_kind(cap_token) != 2) {
main.setError("Wrong capability kind — expected Shell (2)");
return .capability_denied;
}

const p = opaque_handle orelse {
main.clearError();
return .ok;
};

const allocator = std.heap.c_allocator;
const wrapper: *SpawnedChild = @alignCast(@ptrCast(p));

_ = wrapper.child.kill() catch {
// Process may have already exited — fall through to free.
};

allocator.destroy(wrapper);
main.clearError();
return .ok;
}
Loading