From 79fe7486d8b9835a744e736f06b2f3c40e34ed79 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 09:59:52 -0700 Subject: [PATCH 01/12] Add native workspace creation sheet Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 10 + graphcode-windows/src/App.zig | 35 +- graphcode-windows/src/NewWorkspaceForm.zig | 575 +++++++++++++++++++++ 3 files changed, 614 insertions(+), 6 deletions(-) create mode 100644 graphcode-windows/src/NewWorkspaceForm.zig diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index b5ba8a4d..f0a79ddd 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -1201,6 +1201,16 @@ Invoke-Native "Workspace manager form data executable tests" { & $zig test src\WorkspaceManagerForm.zig --test-filter "workspace manager" -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" } finally { Pop-Location } } +Invoke-Native "New workspace form data executable tests" { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\NewWorkspaceForm.zig --test-filter "new workspace" -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$include" + } finally { Pop-Location } +} Invoke-Native "Sidebar navigation executable tests" { Push-Location $shellRoot try { & $zig test src\Navigation.zig } finally { Pop-Location } diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 6a5f354f..a2b36c70 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -57,6 +57,7 @@ const WorkspaceControls = @import("WorkspaceControls.zig"); const WorkspaceLifecycle = @import("WorkspaceLifecycle.zig"); const WorkspaceManager = @import("WorkspaceManager.zig"); const WorkspaceManagerForm = @import("WorkspaceManagerForm.zig"); +const NewWorkspaceForm = @import("NewWorkspaceForm.zig"); const WorkspaceTeardown = @import("WorkspaceTeardown.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; @@ -10152,18 +10153,40 @@ pub const App = struct { } fn createWorkspace(self: *App) void { - const result = self.showWorkspaceText("New Workspace", &.{"Name"}, &.{""}) orelse return; - defer { - var owned = result; - owned.deinit(self.allocator); - } if (!self.ensureWorkspaceIdentity()) return; const home = std.process.getEnvVarOwned(self.allocator, "USERPROFILE") catch { self.setStatus("User profile could not be resolved"); return; }; defer self.allocator.free(home); - var workspace = WorkspaceLifecycle.create(self.allocator, result.values[0], home) catch |err| { + const current_name: []const u8 = name: { + if (self.workspace_list) |list| { + for (list.items) |workspace| { + if (std.mem.eql(u8, workspace.identity, self.workspace_identity)) + break :name workspace.name; + } + } + const basename = std.fs.path.basenameWindows(self.workspace_path); + if (std.mem.eql(u8, basename, WorkspaceLifecycle.default_directory_name)) + break :name "Default"; + if (std.mem.startsWith(u8, basename, WorkspaceLifecycle.directory_prefix) and + basename.len > WorkspaceLifecycle.directory_prefix.len) + break :name basename[WorkspaceLifecycle.directory_prefix.len..]; + break :name if (basename.len == 0) self.workspace_path else basename; + }; + var result = (NewWorkspaceForm.show(self.window.hwnd, self.allocator, .{ + .home = home, + .current_name = current_name, + }) catch |err| { + self.setStatus(if (err == error.FormAlreadyOpen) + "Close the current dialog before managing workspaces" + else + "Workspace dialog could not be completed"); + return; + }) orelse return; + defer result.deinit(self.allocator); + if (!self.ensureWorkspaceIdentity()) return; + var workspace = WorkspaceLifecycle.create(self.allocator, result.name, home) catch |err| { self.setStatus(switch (err) { error.EmptyName => "Workspace name is required", error.NameTooLong => "Workspace name is too long", diff --git a/graphcode-windows/src/NewWorkspaceForm.zig b/graphcode-windows/src/NewWorkspaceForm.zig new file mode 100644 index 00000000..4d8e6b05 --- /dev/null +++ b/graphcode-windows/src/NewWorkspaceForm.zig @@ -0,0 +1,575 @@ +const std = @import("std"); +const Lifecycle = @import("WorkspaceLifecycle.zig"); +const Sheet = @import("SheetFramework.zig"); +const Dpi = @import("Dpi.zig"); +const Win32 = @import("Win32.zig"); +const c = Win32.c; +const AppFont = @import("AppFont.zig"); +const Tokens = @import("DesignTokens.zig"); +const NativeForms = @import("NativeForms.zig"); + +pub const Validator = *const fn (std.mem.Allocator, []const u8, []const u8) anyerror![]u8; +pub const Result = struct { + name: []u8, + + pub fn deinit(self: *Result, allocator: std.mem.Allocator) void { + allocator.free(self.name); + self.* = undefined; + } +}; + +pub const State = struct { + allocator: std.mem.Allocator, + home: []const u8, + current_name: []const u8, + validator: Validator = &Lifecycle.validateName, + draft: []u8 = &.{}, + path: []u8 = &.{}, + problem: ?[]const u8 = null, + + pub fn deinit(self: *State) void { + self.allocator.free(self.draft); + self.allocator.free(self.path); + } + + pub fn update(self: *State, input: []const u8) !void { + const draft = try self.allocator.dupe(u8, input); + errdefer self.allocator.free(draft); + var path: []u8 = &.{}; + errdefer self.allocator.free(path); + var problem: ?[]const u8 = null; + if (std.mem.trim(u8, input, " \t\r\n").len != 0) { + if (self.validator(self.allocator, input, self.home)) |normalized| { + defer self.allocator.free(normalized); + path = try Lifecycle.workspacePath(self.allocator, normalized, self.home); + } else |err| { + problem = switch (err) { + error.EmptyName, error.InvalidName => "Use letters or numbers in the workspace name", + error.NameTooLong => "Workspace name is too long", + error.NameTaken, error.PathAlreadyExists => "That workspace already exists", + else => return err, + }; + } + } + self.allocator.free(self.draft); + self.allocator.free(self.path); + self.draft = draft; + self.path = path; + self.problem = problem; + } + + pub fn canCreate(self: *const State) bool { + return self.path.len != 0 and self.problem == null; + } + + pub fn previewName(self: *const State) []const u8 { + const typed = std.mem.trim(u8, self.draft, " \t\r\n"); + return if (typed.len == 0) "Client Work" else typed; + } + + pub fn submit(self: *State) !?Result { + // Revalidate collisions immediately before handing the draft to App's lifecycle. + try self.update(self.draft); + if (!self.canCreate()) return null; + return .{ .name = try self.allocator.dupe(u8, self.draft) }; + } +}; + +pub const ContentLayout = struct { + title: Sheet.Rect, + name: Sheet.Rect, + detail: Sheet.Rect, + bullets: [3]Sheet.Rect, + preview: Sheet.Rect, + extent: i32, +}; + +pub fn contentLayout(width: i32, dpi: u32) ContentLayout { + const margin = Dpi.scale(24, dpi); + const inner = @max(0, width - margin * 2); + const stacked = width < Dpi.scale(430, dpi); + const preview_width = @min(inner, Dpi.scale(150, dpi)); + const field_width = if (stacked) inner else @max(0, inner - preview_width - Dpi.scale(16, dpi)); + const preview_y = Dpi.scale(if (stacked) @as(i32, 214) else 62, dpi); + return .{ + .title = .{ .x = margin, .y = margin, .width = inner, .height = Dpi.scale(24, dpi) }, + .name = .{ .x = margin, .y = Dpi.scale(62, dpi), .width = field_width, .height = Dpi.scale(32, dpi) }, + .detail = .{ .x = margin, .y = Dpi.scale(102, dpi), .width = field_width, .height = Dpi.scale(20, dpi) }, + .bullets = .{ + .{ .x = margin, .y = Dpi.scale(134, dpi), .width = field_width, .height = Dpi.scale(18, dpi) }, + .{ .x = margin, .y = Dpi.scale(157, dpi), .width = field_width, .height = Dpi.scale(18, dpi) }, + .{ .x = margin, .y = Dpi.scale(180, dpi), .width = field_width, .height = Dpi.scale(18, dpi) }, + }, + .preview = .{ .x = if (stacked) margin else width - margin - preview_width, .y = preview_y, .width = preview_width, .height = Dpi.scale(124, dpi) }, + .extent = if (stacked) preview_y + Dpi.scale(148, dpi) else Dpi.scale(222, dpi), + }; +} + +pub const Options = struct { + home: []const u8, + current_name: []const u8, +}; + +const name_id: u16 = 9751; +const create_id: u16 = 1; +const cancel_id: u16 = 2; +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNewWorkspace"); + +const CommandAction = enum { submit, cancel, change, reveal, blur, ignore }; + +fn commandAction(id: u16, notification: u16) CommandAction { + if (id == create_id and notification == c.BN_CLICKED) return .submit; + if (id == cancel_id and notification == c.BN_CLICKED) return .cancel; + if (id == name_id and notification == c.EN_CHANGE) return .change; + if (id == name_id and notification == c.EN_SETFOCUS) return .reveal; + if (id == name_id and notification == c.EN_KILLFOCUS) return .blur; + return .ignore; +} + +const Dialog = struct { + state: State, + host: ?*Sheet.Host = null, + root: c.HWND = null, + title: c.HWND = null, + name: c.HWND = null, + detail: c.HWND = null, + bullets: [3]c.HWND = .{null} ** 3, + preview_title: c.HWND = null, + preview_empty: c.HWND = null, + caption: c.HWND = null, + geometry: ContentLayout = undefined, + result: ?Result = null, + failure: ?anyerror = null, + + fn fail(self: *Dialog, err: anyerror) void { + self.failure = err; + if (self.host) |host| host.requestClose(); + } + + fn dpi(self: *const Dialog) u32 { + return self.host.?.options.layout_input.dpi; + } +}; + +var active: ?*Dialog = null; + +/// Returns an owned typed name only. App retains identity checks, creation and launch. +pub fn show(parent: c.HWND, allocator: std.mem.Allocator, options: Options) !?Result { + var lease = try NativeForms.ModalLease.acquire(); + defer lease.deinit(); + const home = try allocator.dupe(u8, options.home); + defer allocator.free(home); + const current_name = try allocator.dupe(u8, options.current_name); + defer allocator.free(current_name); + var dialog = Dialog{ .state = .{ .allocator = allocator, .home = home, .current_name = current_name } }; + defer dialog.state.deinit(); + errdefer if (dialog.result) |*result| result.deinit(allocator); + active = &dialog; + defer active = null; + var klass = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = &contentProc; + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = class_name.ptr; + klass.hCursor = c.LoadCursorW(null, Win32.resourceIdentifier(32512)); + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.NewWorkspaceClassFailed; + var host: Sheet.Host = .{}; + defer host.deinit(); + try host.init(parent, .{ + .layout_input = .{ + .owner = .{ .x = 0, .y = 0, .width = 1, .height = 1 }, + .dpi = Win32.dpiForWindow(parent), + .desired_width = 460, + .minimum_width = 320, + .heights = .{ .minimum = 240, .ideal = 440, .maximum = 600 }, + .content_extent = 222, + }, + .context = &dialog, + .on_layout = &layoutDialog, + .on_message = &sheetMessage, + }); + dialog.host = &host; + try setText(allocator, host.hwnd, "New Workspace"); + dialog.root = c.CreateWindowExW(c.WS_EX_CONTROLPARENT, class_name.ptr, null, c.WS_CHILD | c.WS_VISIBLE, 0, 0, 0, 0, host.viewport, null, c.GetModuleHandleW(null), null) orelse return error.NewWorkspaceControlFailed; + dialog.title = try control(&dialog, "STATIC", "New Workspace", 0, 0); + dialog.name = try control(&dialog, "EDIT", "", name_id, c.WS_TABSTOP | c.ES_AUTOHSCROLL); + _ = c.SendMessageW(dialog.name, 0x1501, 1, @bitCast(@intFromPtr(std.unicode.utf8ToUtf16LeStringLiteral("Client Work").ptr))); + dialog.detail = try control(&dialog, "STATIC", "", 0, c.SS_PATHELLIPSIS); + dialog.bullets[0] = try control(&dialog, "STATIC", "+ Its own projects and loops", 0, 0); + dialog.bullets[1] = try control(&dialog, "STATIC", "+ Its own background daemon", 0, 0); + const isolation = try std.fmt.allocPrint(allocator, "- Nothing shared with {s}", .{current_name}); + defer allocator.free(isolation); + dialog.bullets[2] = try control(&dialog, "STATIC", isolation, 0, c.SS_ENDELLIPSIS); + dialog.preview_title = try control(&dialog, "STATIC", "Client Work", 0, c.SS_ENDELLIPSIS); + dialog.preview_empty = try control(&dialog, "STATIC", "empty", 0, c.SS_CENTER); + dialog.caption = try control(&dialog, "STATIC", "A second window, starting empty", 0, c.SS_CENTER); + try host.configureFooter(&.{ + .{ .id = cancel_id, .role = .cancel, .measured_text_width = 0 }, + .{ .id = create_id, .role = .primary, .measured_text_width = 0, .return_hint = true, .enabled = false }, + }); + _ = try host.createFooterButton(cancel_id, std.unicode.utf8ToUtf16LeStringLiteral("Cancel").ptr); + _ = try host.createFooterButton(create_id, std.unicode.utf8ToUtf16LeStringLiteral("Create & Open").ptr); + try host.relayout(); + try host.runModal(); + if (dialog.failure) |err| return err; + return dialog.result; +} + +fn setText(allocator: std.mem.Allocator, hwnd: c.HWND, text: []const u8) !void { + const wide = try std.unicode.utf8ToUtf16LeAllocZ(allocator, text); + defer allocator.free(wide); + if (c.SetWindowTextW(hwnd, wide.ptr) == 0) return error.NewWorkspaceTextFailed; +} + +fn control(dialog: *Dialog, kind: []const u8, text: []const u8, id: u16, style: c.DWORD) !c.HWND { + const allocator = dialog.state.allocator; + const wide_kind = try std.unicode.utf8ToUtf16LeAllocZ(allocator, kind); + defer allocator.free(wide_kind); + const wide_text = try std.unicode.utf8ToUtf16LeAllocZ(allocator, text); + defer allocator.free(wide_text); + const literal_text: c.DWORD = if (std.mem.eql(u8, kind, "STATIC")) c.SS_NOPREFIX else 0; + return c.CreateWindowExW(0, wide_kind.ptr, wide_text.ptr, @as(c.DWORD, c.WS_CHILD | c.WS_VISIBLE) | style | literal_text, 0, 0, 0, 0, dialog.root, Win32.opaquePointerFromInt(c.HMENU, id), c.GetModuleHandleW(null), null) orelse error.NewWorkspaceControlFailed; +} + +fn place(hwnd: c.HWND, bounds: Sheet.Rect, size: i32, bold: bool, dpi: u32) !void { + const font = AppFont.getForDpi(size, bold, dpi) orelse return error.NewWorkspaceFontFailed; + _ = c.SendMessageW(hwnd, c.WM_SETFONT, @intFromPtr(font), 1); + if (c.MoveWindow(hwnd, bounds.x, bounds.y, bounds.width, bounds.height, 1) == 0) + return error.NewWorkspaceLayoutFailed; +} + +fn previewRect(dialog: *const Dialog, x: i32, y: i32, width: i32, height: i32) Sheet.Rect { + return .{ .x = dialog.geometry.preview.x + Dpi.scale(x, dialog.dpi()), .y = dialog.geometry.preview.y + Dpi.scale(y, dialog.dpi()), .width = Dpi.scale(width, dialog.dpi()), .height = Dpi.scale(height, dialog.dpi()) }; +} + +fn layoutDialog(context: ?*anyopaque, layout: Sheet.Layout) !void { + const dialog: *Dialog = @ptrCast(@alignCast(context.?)); + if (dialog.root == null) return; + const host = dialog.host.?; + const dpi = dialog.dpi(); + const scrollbar = if (layout.scroll.visible) Sheet.Metrics.forDpi(dpi).scrollbar_width else 0; + const width = @max(0, layout.viewport.width - scrollbar); + dialog.geometry = contentLayout(width, dpi); + const extent = Dpi.unscale(dialog.geometry.extent, dpi); + if (host.options.layout_input.content_extent != extent) { + try host.setContentExtent(extent); + return; + } + if (c.MoveWindow(dialog.root, 0, -layout.scroll.offset, width, @max(layout.viewport.height, dialog.geometry.extent), 1) == 0) + return error.NewWorkspaceLayoutFailed; + try place(dialog.title, dialog.geometry.title, 15, true, dpi); + const field_inset = Dpi.scale(4, dpi); + var edit_bounds = dialog.geometry.name; + edit_bounds.x += field_inset; + edit_bounds.y += field_inset; + edit_bounds.width = @max(0, edit_bounds.width - field_inset * 2); + edit_bounds.height = @max(0, edit_bounds.height - field_inset * 2); + try place(dialog.name, edit_bounds, 15, false, dpi); + try place(dialog.detail, dialog.geometry.detail, 11, false, dpi); + for (dialog.bullets, dialog.geometry.bullets) |hwnd, bounds| try place(hwnd, bounds, 11, false, dpi); + try place(dialog.preview_title, previewRect(dialog, 55, 4, 84, 11), 7, false, dpi); + try place(dialog.preview_empty, previewRect(dialog, 59, 41, 82, 14), 7, false, dpi); + try place(dialog.caption, previewRect(dialog, 0, 96, 150, 28), 10, false, dpi); +} + +fn readName(dialog: *Dialog) ![]u8 { + const length = c.GetWindowTextLengthW(dialog.name); + if (length < 0) return error.NewWorkspaceNameReadFailed; + const wide = try dialog.state.allocator.alloc(u16, @as(usize, @intCast(length)) + 1); + defer dialog.state.allocator.free(wide); + const copied = c.GetWindowTextW(dialog.name, wide.ptr, @intCast(wide.len)); + if (copied != length) return error.NewWorkspaceNameReadFailed; + return std.unicode.utf16LeToUtf8Alloc(dialog.state.allocator, wide[0..@intCast(copied)]); +} + +fn syncState(dialog: *Dialog) !void { + try setText(dialog.state.allocator, dialog.detail, dialog.state.problem orelse dialog.state.path); + try setText(dialog.state.allocator, dialog.preview_title, dialog.state.previewName()); + _ = c.EnableWindow(c.GetDlgItem(dialog.host.?.hwnd, create_id), @intFromBool(dialog.state.canCreate())); + if (c.InvalidateRect(dialog.root, null, 1) == 0) return error.NewWorkspaceInvalidateFailed; +} + +fn command(dialog: *Dialog, wparam: c.WPARAM) !bool { + const id: u16 = @truncate(wparam); + const notification: u16 = @truncate(wparam >> 16); + switch (commandAction(id, notification)) { + .cancel => { + dialog.host.?.requestClose(); + return true; + }, + .submit => { + const input = try readName(dialog); + defer dialog.state.allocator.free(input); + try dialog.state.update(input); + if (try dialog.state.submit()) |result| { + dialog.result = result; + dialog.host.?.requestClose(); + } else try syncState(dialog); + return true; + }, + .change => { + const input = try readName(dialog); + defer dialog.state.allocator.free(input); + try dialog.state.update(input); + try syncState(dialog); + return true; + }, + .reveal => { + try dialog.host.?.reveal(dialog.geometry.name.y, dialog.geometry.detail.bottom()); + if (c.InvalidateRect(dialog.root, null, 1) == 0) return error.NewWorkspaceInvalidateFailed; + return true; + }, + .blur => { + if (c.InvalidateRect(dialog.root, null, 1) == 0) return error.NewWorkspaceInvalidateFailed; + return true; + }, + .ignore => return false, + } +} + +fn sheetMessage(context: ?*anyopaque, _: c.HWND, message: c.UINT, wparam: c.WPARAM, _: c.LPARAM, result: *c.LRESULT) bool { + const dialog: *Dialog = @ptrCast(@alignCast(context.?)); + if (message == c.WM_SHOWWINDOW and wparam != 0 and dialog.name != null) { + _ = c.SetFocus(dialog.name); + result.* = 0; + return true; + } + if (message == c.DM_GETDEFID) { + result.* = (c.DC_HASDEFID << 16) | create_id; + return true; + } + if (message == c.WM_CLOSE) { + dialog.host.?.requestClose(); + result.* = 0; + return true; + } + if (message != c.WM_COMMAND) return false; + const handled = command(dialog, wparam) catch |err| { + dialog.fail(err); + result.* = 0; + return true; + }; + result.* = 0; + return handled; +} + +fn diagramBox(hdc: c.HDC, bounds: Sheet.Rect, fill: u32, border: u32, dpi: u32) !void { + const brush = c.CreateSolidBrush(fill) orelse return error.NewWorkspacePaintFailed; + defer _ = c.DeleteObject(brush); + const pen = c.CreatePen(c.PS_SOLID, @max(1, Dpi.scale(1, dpi)), border) orelse return error.NewWorkspacePaintFailed; + defer _ = c.DeleteObject(pen); + const old_brush = c.SelectObject(hdc, brush); + defer _ = c.SelectObject(hdc, old_brush); + const old_pen = c.SelectObject(hdc, pen); + defer _ = c.SelectObject(hdc, old_pen); + if (c.RoundRect(hdc, bounds.x, bounds.y, bounds.right(), bounds.bottom(), Dpi.scale(5, dpi), Dpi.scale(5, dpi)) == 0) + return error.NewWorkspacePaintFailed; +} + +fn paintContent(dialog: *Dialog, hdc: c.HDC) !void { + const dpi = dialog.dpi(); + try Sheet.drawField(hdc, dialog.geometry.name, dpi, c.GetFocus() == dialog.name); + try diagramBox(hdc, previewRect(dialog, 0, 10, 108, 74), Tokens.dialog_panel, Tokens.control_border, dpi); + try diagramBox(hdc, previewRect(dialog, 26, 0, 118, 84), Tokens.sheet, Tokens.dialog_muted_text, dpi); + try diagramBox(hdc, previewRect(dialog, 27, 1, 116, 15), Tokens.control_gloss_bottom, Tokens.control_gloss_bottom, dpi); + try diagramBox(hdc, previewRect(dialog, 27, 17, 31, 66), Tokens.workspace_rail, Tokens.workspace_rail, dpi); + for ([_]i32{ 32, 40, 48 }) |x| try diagramBox(hdc, previewRect(dialog, x, 5, 5, 5), Tokens.dialog_muted_text, Tokens.dialog_muted_text, dpi); + try diagramBox(hdc, previewRect(dialog, 32, 23, 20, 3), Tokens.control_border, Tokens.control_border, dpi); + try diagramBox(hdc, previewRect(dialog, 32, 29, 16, 3), Tokens.control_border, Tokens.control_border, dpi); +} + +fn contentProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + const dialog = active orelse return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_COMMAND => { + const handled = command(dialog, wparam) catch |err| { + dialog.fail(err); + return 0; + }; + if (handled) return 0; + }, + c.WM_CTLCOLORSTATIC => { + const hdc = Win32.opaquePointerFromInt(c.HDC, wparam); + const child = Win32.messagePointer(c.HWND, lparam); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + _ = c.SetTextColor(hdc, if (child == dialog.title) Tokens.dialog_title_text else if (child == dialog.detail and dialog.state.problem != null) Tokens.dialog_error_text else Tokens.dialog_muted_text); + if (child == dialog.preview_title or child == dialog.preview_empty) + return @bitCast(@intFromPtr(c.GetStockObject(c.HOLLOW_BRUSH))); + return @bitCast(@intFromPtr(dialog.host.?.brushes.panel)); + }, + c.WM_CTLCOLOREDIT => return dialog.host.?.brushes.apply(Win32.opaquePointerFromInt(c.HDC, wparam), .edit, true) catch |err| { + dialog.fail(err); + return 0; + }, + c.WM_ERASEBKGND => { + var bounds: c.RECT = undefined; + if (c.GetClientRect(hwnd, &bounds) == 0 or c.FillRect(Win32.opaquePointerFromInt(c.HDC, wparam), &bounds, dialog.host.?.brushes.panel) == 0) + dialog.fail(error.NewWorkspacePaintFailed); + return 1; + }, + c.WM_PAINT => { + var paint: c.PAINTSTRUCT = undefined; + const hdc = c.BeginPaint(hwnd, &paint) orelse { + dialog.fail(error.NewWorkspacePaintFailed); + return 0; + }; + paintContent(dialog, hdc) catch |err| dialog.fail(err); + _ = c.EndPaint(hwnd, &paint); + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +const TestValidation = struct { + var calls: usize = 0; + var collision: bool = false; + var failure: bool = false; + + fn validate(allocator: std.mem.Allocator, input: []const u8, _: []const u8) ![]u8 { + calls += 1; + if (failure) return error.AccessDenied; + if (collision) return error.NameTaken; + return Lifecycle.normalizeName(allocator, input); + } + + fn state() State { + calls = 0; + collision = false; + failure = false; + return .{ .allocator = std.testing.allocator, .home = "C:\\OwnedHome", .current_name = "Default", .validator = &validate }; + } +}; + +test "new workspace empty draft is quiet and cannot submit" { + var state = TestValidation.state(); + defer state.deinit(); + try state.update(" "); + try std.testing.expect(state.problem == null); + try std.testing.expect(!state.canCreate()); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try std.testing.expectEqualStrings("", state.path); + try std.testing.expect((try state.submit()) == null); + try std.testing.expectEqual(@as(usize, 0), TestValidation.calls); +} + +test "new workspace typed title and resolved directory remain distinct" { + var state = TestValidation.state(); + defer state.deinit(); + try state.update(" Client Work "); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-client-work", state.path); + try std.testing.expect(state.canCreate()); + var result = (try state.submit()).?; + defer result.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(" Client Work ", result.name); + try std.testing.expectEqual(@as(usize, 2), TestValidation.calls); +} + +test "new workspace invalid draft retains typed preview and clears prior path" { + var state = TestValidation.state(); + defer state.deinit(); + try state.update("Valid"); + try state.update("!!!"); + try std.testing.expectEqualStrings("!!!", state.previewName()); + try std.testing.expectEqualStrings("", state.path); + try std.testing.expect(state.problem != null); + try std.testing.expect(!state.canCreate()); + try std.testing.expect((try state.submit()) == null); +} + +test "new workspace collision at submit stays inline without a creation result" { + var state = TestValidation.state(); + defer state.deinit(); + try state.update("Client Work"); + TestValidation.collision = true; + try std.testing.expect((try state.submit()) == null); + try std.testing.expect(state.problem != null); + try std.testing.expectEqualStrings("That workspace already exists", state.problem.?); + try std.testing.expect(!state.canCreate()); + TestValidation.collision = false; + try state.update("Client Work 2"); + try std.testing.expect(state.problem == null); + try std.testing.expect(state.canCreate()); +} + +test "new workspace validation operational failures propagate" { + var state = TestValidation.state(); + defer state.deinit(); + TestValidation.failure = true; + try std.testing.expectError(error.AccessDenied, state.update("Client Work")); +} + +test "new workspace name limit follows existing lifecycle without truncation" { + var state = TestValidation.state(); + defer state.deinit(); + try state.update(&(.{'a'} ** Lifecycle.max_name_length)); + try std.testing.expect(state.canCreate()); + try state.update(&(.{'a'} ** (Lifecycle.max_name_length + 1))); + try std.testing.expect(state.problem != null); + try std.testing.expectEqualStrings("Workspace name is too long", state.problem.?); + try std.testing.expect(!state.canCreate()); +} + +test "new workspace layout keeps fields and diagram separate at supported DPIs" { + for ([_]u32{ 96, 144, 192 }) |dpi| { + const layout = contentLayout(Dpi.scale(460, dpi), dpi); + try std.testing.expect(layout.name.width >= Dpi.scale(200, dpi)); + try std.testing.expect(layout.name.right() + Dpi.scale(16, dpi) <= layout.preview.x); + try std.testing.expect(layout.title.bottom() < layout.name.y); + try std.testing.expect(layout.detail.bottom() <= layout.bullets[0].y); + try std.testing.expect(layout.preview.bottom() <= layout.extent); + try std.testing.expect(layout.bullets[2].bottom() <= layout.extent); + } +} + +test "new workspace narrow layout stacks preview without overlapping controls" { + const layout = contentLayout(340, 96); + try std.testing.expect(layout.name.width > 0); + try std.testing.expect(layout.preview.y >= layout.bullets[2].bottom() + 14); + try std.testing.expect(layout.preview.right() <= 340 - 24); + try std.testing.expect(layout.extent >= layout.preview.bottom() + 24); +} + +test "new workspace native entrypoint links without opening a window" { + std.mem.doNotOptimizeAway(&show); +} + +test "new workspace create cancel and editing notifications route without accidental submission" { + try std.testing.expectEqual(CommandAction.submit, commandAction(create_id, 0)); + try std.testing.expectEqual(CommandAction.cancel, commandAction(cancel_id, 0)); + try std.testing.expectEqual(CommandAction.change, commandAction(name_id, c.EN_CHANGE)); + try std.testing.expectEqual(CommandAction.reveal, commandAction(name_id, c.EN_SETFOCUS)); + try std.testing.expectEqual(CommandAction.blur, commandAction(name_id, c.EN_KILLFOCUS)); + try std.testing.expectEqual(CommandAction.ignore, commandAction(create_id, c.BN_SETFOCUS)); + try std.testing.expectEqual(CommandAction.ignore, commandAction(cancel_id, c.BN_SETFOCUS)); + try std.testing.expectEqual(CommandAction.ignore, commandAction(9999, 0)); +} + +test "new workspace presentation preserves Unicode and ampersands without changing directory rules" { + var state = TestValidation.state(); + defer state.deinit(); + try state.update("Caf\xc3\xa9 R&D"); + try std.testing.expectEqualStrings("Caf\xc3\xa9 R&D", state.previewName()); + try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-caf-r-d", state.path); + var result = (try state.submit()).?; + defer result.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("Caf\xc3\xa9 R&D", result.name); +} + +test "new workspace state and handoff release every allocation on failure" { + const Exercise = struct { + fn run(allocator: std.mem.Allocator) !void { + var state = State{ .allocator = allocator, .home = "C:\\OwnedHome", .current_name = "Default", .validator = &TestValidation.validate }; + defer state.deinit(); + try state.update("Client Work"); + try state.update("Client Work 2"); + var result = (try state.submit()) orelse return error.ExpectedWorkspaceResult; + defer result.deinit(allocator); + } + }; + TestValidation.collision = false; + TestValidation.failure = false; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Exercise.run, .{}); +} From d84701b876f170d0782eaf1faa51a1ef1f9f2393 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 10:04:22 -0700 Subject: [PATCH 02/12] Wrap the current workspace isolation label Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/NewWorkspaceForm.zig | 47 ++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/graphcode-windows/src/NewWorkspaceForm.zig b/graphcode-windows/src/NewWorkspaceForm.zig index 4d8e6b05..483119a0 100644 --- a/graphcode-windows/src/NewWorkspaceForm.zig +++ b/graphcode-windows/src/NewWorkspaceForm.zig @@ -82,6 +82,15 @@ pub const ContentLayout = struct { bullets: [3]Sheet.Rect, preview: Sheet.Rect, extent: i32, + + pub fn withIsolationHeight(self: ContentLayout, height: i32, dpi: u32) ContentLayout { + var result = self; + result.bullets[2].height = @max(self.bullets[2].height, height); + if (result.preview.x == result.name.x) + result.preview.y = @max(result.preview.y, result.bullets[2].bottom() + Dpi.scale(14, dpi)); + result.extent = @max(result.extent, @max(result.bullets[2].bottom(), result.preview.bottom()) + Dpi.scale(24, dpi)); + return result; + } }; pub fn contentLayout(width: i32, dpi: u32) ContentLayout { @@ -137,6 +146,7 @@ const Dialog = struct { preview_title: c.HWND = null, preview_empty: c.HWND = null, caption: c.HWND = null, + isolation: []const u8 = "", geometry: ContentLayout = undefined, result: ?Result = null, failure: ?anyerror = null, @@ -199,7 +209,8 @@ pub fn show(parent: c.HWND, allocator: std.mem.Allocator, options: Options) !?Re dialog.bullets[1] = try control(&dialog, "STATIC", "+ Its own background daemon", 0, 0); const isolation = try std.fmt.allocPrint(allocator, "- Nothing shared with {s}", .{current_name}); defer allocator.free(isolation); - dialog.bullets[2] = try control(&dialog, "STATIC", isolation, 0, c.SS_ENDELLIPSIS); + dialog.isolation = isolation; + dialog.bullets[2] = try control(&dialog, "STATIC", isolation, 0, 0); dialog.preview_title = try control(&dialog, "STATIC", "Client Work", 0, c.SS_ENDELLIPSIS); dialog.preview_empty = try control(&dialog, "STATIC", "empty", 0, c.SS_CENTER); dialog.caption = try control(&dialog, "STATIC", "A second window, starting empty", 0, c.SS_CENTER); @@ -244,15 +255,17 @@ fn previewRect(dialog: *const Dialog, x: i32, y: i32, width: i32, height: i32) S fn layoutDialog(context: ?*anyopaque, layout: Sheet.Layout) !void { const dialog: *Dialog = @ptrCast(@alignCast(context.?)); - if (dialog.root == null) return; + if (dialog.root == null or dialog.bullets[2] == null) return; const host = dialog.host.?; const dpi = dialog.dpi(); const scrollbar = if (layout.scroll.visible) Sheet.Metrics.forDpi(dpi).scrollbar_width else 0; const width = @max(0, layout.viewport.width - scrollbar); dialog.geometry = contentLayout(width, dpi); + dialog.geometry = dialog.geometry.withIsolationHeight(try isolationHeight(dialog), dpi); const extent = Dpi.unscale(dialog.geometry.extent, dpi); if (host.options.layout_input.content_extent != extent) { - try host.setContentExtent(extent); + host.options.layout_input.content_extent = extent; + try host.refreshOwnerGeometry(); return; } if (c.MoveWindow(dialog.root, 0, -layout.scroll.offset, width, @max(layout.viewport.height, dialog.geometry.extent), 1) == 0) @@ -272,6 +285,20 @@ fn layoutDialog(context: ?*anyopaque, layout: Sheet.Layout) !void { try place(dialog.caption, previewRect(dialog, 0, 96, 150, 28), 10, false, dpi); } +fn isolationHeight(dialog: *const Dialog) !i32 { + const wide = try std.unicode.utf8ToUtf16LeAllocZ(dialog.state.allocator, dialog.isolation); + defer dialog.state.allocator.free(wide); + const dc = c.GetDC(dialog.root) orelse return error.NewWorkspaceTextMeasurementFailed; + defer _ = c.ReleaseDC(dialog.root, dc); + const font = AppFont.getForDpi(11, false, dialog.dpi()) orelse return error.NewWorkspaceFontFailed; + const old_font = c.SelectObject(dc, font); + defer _ = c.SelectObject(dc, old_font); + var bounds = c.RECT{ .left = 0, .top = 0, .right = dialog.geometry.bullets[2].width, .bottom = 0 }; + if (c.DrawTextW(dc, wide.ptr, @intCast(wide.len), &bounds, c.DT_CALCRECT | c.DT_WORDBREAK | c.DT_NOPREFIX) == 0) + return error.NewWorkspaceTextMeasurementFailed; + return bounds.bottom; +} + fn readName(dialog: *Dialog) ![]u8 { const length = c.GetWindowTextLengthW(dialog.name); if (length < 0) return error.NewWorkspaceNameReadFailed; @@ -573,3 +600,17 @@ test "new workspace state and handoff release every allocation on failure" { TestValidation.failure = false; try std.testing.checkAllAllocationFailures(std.testing.allocator, Exercise.run, .{}); } + +test "new workspace long isolation text grows content and keeps the stacked preview below it" { + for ([_]u32{ 96, 144, 192 }) |dpi| { + for ([_]i32{ 340, 460 }) |width| { + const base = contentLayout(Dpi.scale(width, dpi), dpi); + const grown = base.withIsolationHeight(Dpi.scale(54, dpi), dpi); + try std.testing.expectEqual(Dpi.scale(54, dpi), grown.bullets[2].height); + try std.testing.expect(grown.extent >= grown.bullets[2].bottom() + Dpi.scale(24, dpi)); + if (width == 340) + try std.testing.expect(grown.preview.y >= grown.bullets[2].bottom() + Dpi.scale(14, dpi)); + try std.testing.expect(grown.preview.bottom() + Dpi.scale(24, dpi) <= grown.extent); + } + } +} From 56ee8414347dc630ddfc3f103c795fecdec43115 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 10:10:41 -0700 Subject: [PATCH 03/12] Require explicit provider root for workspace form tests Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index f0a79ddd..e5e671e8 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -1202,9 +1202,10 @@ Invoke-Native "Workspace manager form data executable tests" { } finally { Pop-Location } } Invoke-Native "New workspace form data executable tests" { - $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") - if (-not $winghosttyRoot) { $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" } + if ([string]::IsNullOrWhiteSpace($winghosttyRoot)) { + throw "New workspace form tests require GRAPHCODE_WINGHOSTTY_ROOT from Tools\windows\bootstrap.ps1." + } $include = Join-Path $winghosttyRoot "include" Push-Location $shellRoot try { From 3f1446378a992019cd07047f75be64d01a46d1e5 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 13:43:06 -0700 Subject: [PATCH 04/12] Handle select all in the workspace name edit Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/NewWorkspaceForm.zig | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/graphcode-windows/src/NewWorkspaceForm.zig b/graphcode-windows/src/NewWorkspaceForm.zig index 483119a0..9394d9fa 100644 --- a/graphcode-windows/src/NewWorkspaceForm.zig +++ b/graphcode-windows/src/NewWorkspaceForm.zig @@ -141,6 +141,7 @@ const Dialog = struct { root: c.HWND = null, title: c.HWND = null, name: c.HWND = null, + name_binding: NameEditBinding = .{}, detail: c.HWND = null, bullets: [3]c.HWND = .{null} ** 3, preview_title: c.HWND = null, @@ -163,6 +164,51 @@ const Dialog = struct { var active: ?*Dialog = null; +const NameKeyAction = enum { forward, select_all, consume }; + +fn nameKeyAction(message: c.UINT, key: c.WPARAM, ctrl: bool, alt: bool, focused: bool) NameKeyAction { + if (!focused or !ctrl or alt) return .forward; + if (message == c.WM_KEYDOWN and key == 'A') return .select_all; + if (message == c.WM_CHAR and key == 1) return .consume; + return .forward; +} + +const NameEditBinding = struct { + hwnd: c.HWND = null, + original: c.WNDPROC = null, + + fn owns(self: NameEditBinding, hwnd: c.HWND) bool { + return hwnd != null and self.hwnd == hwnd and self.original != null; + } + + fn destroyed(self: *NameEditBinding, hwnd: c.HWND) void { + if (self.hwnd == hwnd) self.* = .{}; + } +}; + +fn nameEditProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + const dialog = active orelse return c.DefWindowProcW(hwnd, message, wparam, lparam); + if (!dialog.name_binding.owns(hwnd)) return c.DefWindowProcW(hwnd, message, wparam, lparam); + const original = dialog.name_binding.original; + if (message == c.WM_NCDESTROY) { + // Host teardown runs before the stack-owned dialog and modal lease expire. + dialog.name_binding.destroyed(hwnd); + dialog.name = null; + return c.CallWindowProcW(original, hwnd, message, wparam, lparam); + } + if (message == c.WM_KEYDOWN or message == c.WM_CHAR) { + switch (nameKeyAction(message, wparam, c.GetKeyState(c.VK_CONTROL) < 0, c.GetKeyState(c.VK_MENU) < 0, c.GetFocus() == hwnd)) { + .select_all => { + _ = c.SendMessageW(hwnd, c.EM_SETSEL, 0, -1); + return 0; + }, + .consume => return 0, + .forward => {}, + } + } + return c.CallWindowProcW(original, hwnd, message, wparam, lparam); +} + /// Returns an owned typed name only. App retains identity checks, creation and launch. pub fn show(parent: c.HWND, allocator: std.mem.Allocator, options: Options) !?Result { var lease = try NativeForms.ModalLease.acquire(); @@ -203,6 +249,12 @@ pub fn show(parent: c.HWND, allocator: std.mem.Allocator, options: Options) !?Re dialog.root = c.CreateWindowExW(c.WS_EX_CONTROLPARENT, class_name.ptr, null, c.WS_CHILD | c.WS_VISIBLE, 0, 0, 0, 0, host.viewport, null, c.GetModuleHandleW(null), null) orelse return error.NewWorkspaceControlFailed; dialog.title = try control(&dialog, "STATIC", "New Workspace", 0, 0); dialog.name = try control(&dialog, "EDIT", "", name_id, c.WS_TABSTOP | c.ES_AUTOHSCROLL); + const previous = c.SetWindowLongPtrW(dialog.name, c.GWLP_WNDPROC, @bitCast(@intFromPtr(&nameEditProc))); + if (previous == 0) return error.NewWorkspaceNameSubclassFailed; + dialog.name_binding = .{ + .hwnd = dialog.name, + .original = Win32.opaquePointerFromInt(c.WNDPROC, @bitCast(previous)), + }; _ = c.SendMessageW(dialog.name, 0x1501, 1, @bitCast(@intFromPtr(std.unicode.utf8ToUtf16LeStringLiteral("Client Work").ptr))); dialog.detail = try control(&dialog, "STATIC", "", 0, c.SS_PATHELLIPSIS); dialog.bullets[0] = try control(&dialog, "STATIC", "+ Its own projects and loops", 0, 0); @@ -614,3 +666,43 @@ test "new workspace long isolation text grows content and keeps the stacked prev } } } + +test "new workspace Ctrl A policy selects all only in its focused name edit" { + try std.testing.expectEqual(NameKeyAction.select_all, nameKeyAction(c.WM_KEYDOWN, 'A', true, false, true)); + try std.testing.expectEqual(NameKeyAction.consume, nameKeyAction(c.WM_CHAR, 1, true, false, true)); + for ([_]c.UINT{ c.WM_KEYDOWN, c.WM_SYSKEYDOWN, c.WM_KEYUP, c.WM_CHAR }) |message| { + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(message, 'A', false, false, true)); + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(message, 'A', true, true, true)); + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(message, 'A', true, false, false)); + } + for ([_]c.WPARAM{ 'B', c.VK_DELETE, c.VK_BACK, c.VK_RETURN, c.VK_ESCAPE }) |key| + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(c.WM_KEYDOWN, key, true, false, true)); + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(c.WM_CHAR, 'a', false, false, true)); + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(c.WM_CHAR, 1, true, true, true)); + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(c.WM_CHAR, 3, true, false, true)); + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(c.WM_KEYUP, 'A', true, false, true)); + try std.testing.expectEqual(NameKeyAction.forward, nameKeyAction(c.WM_SYSKEYDOWN, 'A', true, false, true)); +} + +test "new workspace edit subclass binding ends with its exact control lifetime" { + const Probe = struct { + fn original(_: c.HWND, _: c.UINT, _: c.WPARAM, _: c.LPARAM) callconv(.winapi) c.LRESULT { + return 0; + } + }; + const owned = Win32.opaquePointerFromInt(c.HWND, 9751); + const foreign = Win32.opaquePointerFromInt(c.HWND, 9752); + var binding = NameEditBinding{ .hwnd = owned, .original = &Probe.original }; + try std.testing.expect(binding.owns(owned)); + try std.testing.expect(!binding.owns(foreign)); + try std.testing.expect(!binding.owns(null)); + binding.destroyed(foreign); + try std.testing.expect(binding.owns(owned)); + binding.destroyed(owned); + try std.testing.expect(!binding.owns(owned)); + try std.testing.expect(binding.hwnd == null and binding.original == null); + binding.destroyed(owned); + try std.testing.expect(!binding.owns(owned)); + binding.hwnd = owned; + try std.testing.expect(!binding.owns(owned)); +} From df0a80cc96488b42b563898cd7418f274b8ae28d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 14:19:15 -0700 Subject: [PATCH 05/12] Expose read-only kernel pipe server identity for capture checks Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 173 ++++++++++++++++++ Tools/windows/UiCaptureDriver.psm1 | 15 ++ 2 files changed, 188 insertions(+) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 9b92ad3f..687ea2cc 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -13,6 +13,8 @@ param( [switch]$ActionBudgetContractOnly, [switch]$PreflightReportContractOnly, [switch]$NativeBindingsContractOnly, + [switch]$NativePipePeerContractOnly, + [ValidateSet('All','SourceOnly')][string]$NativePipePeerCase = 'All', [ValidateSet('All','Accepted')][string]$NativeBindingCase = 'All', [switch]$SelectorRefusalContractOnly, [switch]$IdentityContractOnly, @@ -7620,6 +7622,177 @@ function Invoke-CapIdentityContracts { } } +if ($NativePipePeerContractOnly) { + $expectedNames = @( + 'pipe peer helper exposes only a safe pipe handle query' + 'pipe peer native source preserves error and nonzero guards' + 'pipe peer rejects null invalid and disposed handles before native access' + ) + if ($NativePipePeerCase -ceq 'All') { + $expectedNames += @( + 'pipe peer identifies the actual connected owned server within one deadline' + 'pipe peer preserves the kernel error for a nonpipe handle' + ) + } + + Invoke-CapTest 'pipe peer helper exposes only a safe pipe handle query' { + $method = [CapNative].GetMethod('PipeServerProcessId', [Reflection.BindingFlags]'Public,Static') + Assert-CapTest ($null -ne $method) 'Missing read-only pipe peer helper.' + $parameters = $method.GetParameters() + Assert-CapTest ($parameters.Count -eq 1 -and + $parameters[0].ParameterType -eq [Microsoft.Win32.SafeHandles.SafePipeHandle] -and + $method.ReturnType -eq [int]) 'Pipe peer helper must take a SafePipeHandle and return a PID only.' + return @{ parameters = 1; returns = 'System.Int32'; nativeCalls = 0 } + } + + Invoke-CapTest 'pipe peer native source preserves error and nonzero guards' { + $source = Get-Content -LiteralPath $modulePath -Raw + $declaration = [regex]::Matches($source, + '(?s)\[DllImport\("kernel32\.dll", SetLastError=true\)\]\s*private static extern bool GetNamedPipeServerProcessId\(Microsoft\.Win32\.SafeHandles\.SafePipeHandle pipe, out uint processId\);') + $methods = [regex]::Matches($source, + '(?s)public static int PipeServerProcessId\(Microsoft\.Win32\.SafeHandles\.SafePipeHandle pipe\) \{(?.*?)\n \}') + Assert-CapTest ($declaration.Count -eq 1 -and $methods.Count -eq 1) 'Expected exactly one safe-handle kernel declaration and public query.' + $body = $methods[0].Groups['body'].Value + $nullGuard = $body.IndexOf('pipe == null', [StringComparison]::Ordinal) + $closedGuard = $body.IndexOf('pipe.IsClosed', [StringComparison]::Ordinal) + $invalidGuard = $body.IndexOf('pipe.IsInvalid', [StringComparison]::Ordinal) + $query = $body.IndexOf('GetNamedPipeServerProcessId(pipe, out processId)', [StringComparison]::Ordinal) + Assert-CapTest ($nullGuard -ge 0 -and $closedGuard -gt $nullGuard -and + $invalidGuard -gt $closedGuard -and $query -gt $invalidGuard -and + $body.Contains('throw new Win32Exception(Marshal.GetLastWin32Error())') -and + $body.Contains('if (processId == 0) throw new InvalidOperationException(') -and + $body.Contains('return checked((int)processId);') -and + -not $body.Contains('DangerousGetHandle') -and + -not $body.Contains('OpenProcess') -and -not $body.Contains('CloseHandle')) ` + 'Pipe peer query lost guard ordering, kernel error, zero refusal or read-only handle ownership.' + Assert-CapTest ($source.Contains('[Microsoft.Win32.SafeHandles.SafePipeHandle].Assembly.Location')) ` + 'Native helper compilation must reference the actual SafePipeHandle assembly.' + return @{ declarations = 1; nativeCalls = 0; processControlCalls = 0 } + } + + Invoke-CapTest 'pipe peer rejects null invalid and disposed handles before native access' { + $zero = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::Zero, $false) + $minusOne = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::new(-1), $false) + $disposed = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::new(-2), $false) + $disposed.Dispose() + try { + $cases = @( + @{ handle = $null; expected = [ArgumentNullException] }, + @{ handle = $zero; expected = [ArgumentException] }, + @{ handle = $minusOne; expected = [ArgumentException] }, + @{ handle = $disposed; expected = [ObjectDisposedException] } + ) + $refused = 0 + foreach ($case in $cases) { + $failure = $null + try { $null = [CapNative]::PipeServerProcessId($case.handle) } catch { + $failure = $_.Exception + while ($failure.InnerException) { $failure = $failure.InnerException } + } + Assert-CapTest ($null -ne $failure -and $failure.GetType() -eq $case.expected) ` + 'Null, invalid or disposed pipe handle did not fail before native access.' + $refused++ + } + return @{ refused = $refused; nativeCalls = 0; handlesOwned = 0 } + } finally { + $zero.Dispose() + $minusOne.Dispose() + $disposed.Dispose() + } + } + + if ($NativePipePeerCase -ceq 'All') { + Invoke-CapTest 'pipe peer identifies the actual connected owned server within one deadline' { + $pipeName = 'graphcode-peer-contract-' + [guid]::NewGuid().ToString('N') + $limit = [Threading.CancellationTokenSource]::new(5000) + $server = $null + $client = $null + $waiting = $null + $primary = $null + try { + $server = [IO.Pipes.NamedPipeServerStream]::new($pipeName, [IO.Pipes.PipeDirection]::InOut, + 1, [IO.Pipes.PipeTransmissionMode]::Byte, + ([IO.Pipes.PipeOptions]::Asynchronous -bor [IO.Pipes.PipeOptions]::CurrentUserOnly)) + $client = [IO.Pipes.NamedPipeClientStream]::new('.', $pipeName, + [IO.Pipes.PipeDirection]::InOut, [IO.Pipes.PipeOptions]::Asynchronous) + $waiting = $server.WaitForConnectionAsync($limit.Token) + $null = $client.ConnectAsync($limit.Token).GetAwaiter().GetResult() + $null = $waiting.GetAwaiter().GetResult() + $before = [CapNative]::PipeServerProcessId($client.SafePipeHandle) + Assert-CapTest ($before -gt 0 -and $before -eq [Environment]::ProcessId) ` + 'Kernel peer differs from the known owned same-process server.' + $payload = [byte[]]@(11, 22, 33) + $null = $client.WriteAsync($payload, 0, $payload.Length, $limit.Token).GetAwaiter().GetResult() + $read = [byte[]]::new($payload.Length) + $offset = 0 + while ($offset -lt $read.Length) { + $count = $server.ReadAsync($read, $offset, ($read.Length - $offset), $limit.Token).GetAwaiter().GetResult() + Assert-CapTest ($count -gt 0) 'Owned pipe ended before its complete payload.' + $offset += $count + } + $after = [CapNative]::PipeServerProcessId($client.SafePipeHandle) + Assert-CapTest ($after -eq $before -and ($read -join ',') -ceq ($payload -join ',')) ` + 'Kernel peer or actual owned pipe payload changed during the live connection.' + return @{ before = $before; after = $after; payloadBytes = $offset + scope = 'ACTUAL_OBSERVER_CONNECTION_ONLY_NOT_ANOTHER_APP_CONNECTION' } + } catch { + $primary = $_.Exception + throw + } finally { + $limit.Cancel() + if ($null -ne $client) { $client.Dispose() } + if ($null -ne $server) { $server.Dispose() } + try { + if ($null -ne $waiting) { + try { $null = $waiting.GetAwaiter().GetResult() } catch { + $cause = $_.Exception + while ($cause.InnerException) { $cause = $cause.InnerException } + if ($cause -isnot [OperationCanceledException] -or -not $limit.IsCancellationRequested) { + if ($null -eq $primary) { throw } + $primary.Data['CapPipeWaitCleanupFailure'] = $cause.ToString() + } + } + } + } finally { $limit.Dispose() } + } + } + + Invoke-CapTest 'pipe peer preserves the kernel error for a nonpipe handle' { + # -2 is the current-thread pseudo-handle, never a pipe; this wrapper owns nothing. + $notPipe = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::new(-2), $false) + try { + $failure = $null + try { $null = [CapNative]::PipeServerProcessId($notPipe) } catch { + $failure = $_.Exception + while ($failure.InnerException) { $failure = $failure.InnerException } + } + Assert-CapTest ($failure -is [ComponentModel.Win32Exception] -and $failure.NativeErrorCode -eq 6) ` + 'Expected the real ERROR_INVALID_HANDLE from a nonpipe handle.' + return @{ nativeErrorCode = $failure.NativeErrorCode; handlesOwned = 0 } + } finally { $notPipe.Dispose() } + } + } + + foreach ($path in $sourceHashesBefore.Keys) { + Assert-CapTest ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ceq $sourceHashesBefore[$path]) ` + "Source changed during pipe peer contracts: $path" + } + $names = @($results | ForEach-Object { $_.name }) + Assert-CapTest ($names.Count -eq $expectedNames.Count -and + ($names -join '|') -ceq ($expectedNames -join '|') -and + @($names | Sort-Object -Unique).Count -eq $expectedNames.Count) 'Pipe peer literal case registry differs.' + $passed = @($results | Where-Object passed -EQ $true).Count + $failed = @($results | Where-Object passed -EQ $false).Count + $report = [pscustomobject]@{ suite = 'UiCaptureNativePipePeerContracts'; executed = $results.Count + passed = $passed; failed = $failed; cases = @($results); sourceHashesBeforeImport = $sourceHashesBefore + mode = if ($NativePipePeerCase -ceq 'SourceOnly') { 'PURE_SOURCE_AND_MANAGED_GUARDS_NO_KERNEL_PIPE_QUERY' } + else { 'OWNED_SAME_PROCESS_PIPE_NO_GUI_NO_PROCESS_CONTROL' } } + $report | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM + $report | Select-Object suite,executed,passed,failed | ConvertTo-Json -Compress + if ($results.Count -ne $expectedNames.Count -or $passed -ne $expectedNames.Count -or $failed -ne 0) { exit 1 } + exit 0 +} + if ($IdentityContractOnly) { Invoke-CapIdentityContracts foreach ($path in $sourceHashesBefore.Keys) { diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 01def866..753d86bd 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -264,6 +264,8 @@ public static class CapNative { [DllImport("user32.dll", SetLastError=true)] private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern bool WaitNamedPipeW(string name, uint timeout); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetNamedPipeServerProcessId(Microsoft.Win32.SafeHandles.SafePipeHandle pipe, out uint processId); private static IntPtr EnterPerMonitorV2() { IntPtr previous = SetThreadDpiAwarenessContext(new IntPtr(-4)); @@ -634,6 +636,18 @@ public static class CapNative { return Marshal.GetLastWin32Error(); } + // This connection's kernel peer is not evidence of another client's connection or process custody. + public static int PipeServerProcessId(Microsoft.Win32.SafeHandles.SafePipeHandle pipe) { + if (pipe == null) throw new ArgumentNullException("pipe"); + if (pipe.IsClosed) throw new ObjectDisposedException("pipe"); + if (pipe.IsInvalid) throw new ArgumentException("A valid connected pipe handle is required.", "pipe"); + uint processId; + if (!GetNamedPipeServerProcessId(pipe, out processId)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + if (processId == 0) throw new InvalidOperationException("The pipe server PID is unavailable."); + return checked((int)processId); + } + [DllImport("user32.dll", SetLastError=true)] private static extern bool PrintWindow(IntPtr window, IntPtr dc, uint flags); } @@ -644,6 +658,7 @@ if (-not ('CapNative' -as [type])) { [System.Drawing.Bitmap].Assembly.Location, [System.Drawing.Graphics].Assembly.Location, [System.Drawing.Color].Assembly.Location, + [Microsoft.Win32.SafeHandles.SafePipeHandle].Assembly.Location, (Join-Path $PSHOME 'System.Private.Windows.Core.dll'), (Join-Path $PSHOME 'System.Private.Windows.GdiPlus.dll'), 'System.Collections.dll', From 2379a6147f440ace58f0cd70f0ef193f5faba18a Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 14:34:12 -0700 Subject: [PATCH 06/12] Report explicit skipped count for pipe peer contracts Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 687ea2cc..91f1784f 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -7784,11 +7784,11 @@ if ($NativePipePeerContractOnly) { $passed = @($results | Where-Object passed -EQ $true).Count $failed = @($results | Where-Object passed -EQ $false).Count $report = [pscustomobject]@{ suite = 'UiCaptureNativePipePeerContracts'; executed = $results.Count - passed = $passed; failed = $failed; cases = @($results); sourceHashesBeforeImport = $sourceHashesBefore + passed = $passed; failed = $failed; skipped = 0; cases = @($results); sourceHashesBeforeImport = $sourceHashesBefore mode = if ($NativePipePeerCase -ceq 'SourceOnly') { 'PURE_SOURCE_AND_MANAGED_GUARDS_NO_KERNEL_PIPE_QUERY' } else { 'OWNED_SAME_PROCESS_PIPE_NO_GUI_NO_PROCESS_CONTROL' } } $report | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM - $report | Select-Object suite,executed,passed,failed | ConvertTo-Json -Compress + $report | Select-Object suite,executed,passed,failed,skipped | ConvertTo-Json -Compress if ($results.Count -ne $expectedNames.Count -or $passed -ne $expectedNames.Count -or $failed -ne 0) { exit 1 } exit 0 } From 8aec6ba1133a40d6940d33d4ffcda8dbc5e1b179 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 14:36:00 -0700 Subject: [PATCH 07/12] Require pipe peer contracts before capture fixture preparation Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index 5520b1dc..e540333c 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -107,6 +107,11 @@ public async Task> RunAsync(ValidationContext context, const string script = "Tools\\windows\\Tests\\UiCaptureDriver.Tests.ps1"; var captureRoot = PowerShellPayload.CreateScratchRoot(context.RepositoryRoot); await File.WriteAllTextAsync(Path.Combine(context.ArtifactRoot, "capture-root.txt"), captureRoot, cancellationToken); + var peerContracts = await PowerShellPayload.RunAsync(context, Id + ".native-pipe-peer", script, + ["-NativePipePeerContractOnly", "-ScratchRoot", Path.Combine(captureRoot, "native-pipe-peer")], + cancellationToken); + if (EvidenceParser.Structured(Id, "UiCaptureNativePipePeerContracts", peerContracts.Output, 5, 5).Status != UnitStatus.Passed) + throw new InvalidDataException("Native pipe peer contracts failed"); var prebuilt = Path.Combine(captureRoot, "prebuilt"); await PowerShellPayload.RunAsync(context, Id + ".prepare", script, ["-PrepareFixturesOnly", "-ScratchRoot", prebuilt], cancellationToken); From d621c4948b5b5ee2172e4ab9ae6e8c9db9bf6857 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 6 Nov 2026 15:23:10 -0700 Subject: [PATCH 08/12] Post owned pipe reader before awaiting payload completion Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 75 +++++++++++++++++-- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 91f1784f..fca59019 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -7667,6 +7667,32 @@ if ($NativePipePeerContractOnly) { 'Pipe peer query lost guard ordering, kernel error, zero refusal or read-only handle ownership.' Assert-CapTest ($source.Contains('[Microsoft.Win32.SafeHandles.SafePipeHandle].Assembly.Location')) ` 'Native helper compilation must reference the actual SafePipeHandle assembly.' + $tokens = $null + $parseErrors = $null + $testAst = [Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$tokens, [ref]$parseErrors) + $pipeCases = @($testAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-CapTest' -and + $node.CommandElements.Count -ge 3 -and + $node.CommandElements[1] -is [Management.Automation.Language.StringConstantExpressionAst] -and + $node.CommandElements[1].Value -ceq 'pipe peer identifies the actual connected owned server within one deadline' + }, $true)) + Assert-CapTest ($parseErrors.Count -eq 0 -and $pipeCases.Count -eq 1) 'Owned pipe case must parse exactly once.' + $testSource = $pipeCases[0].CommandElements[2].Extent.Text + $posted = $testSource.IndexOf('$pendingRead = $server.ReadAsync($read, 0, $read.Length, $limit.Token)', [StringComparison]::Ordinal) + $write = $testSource.IndexOf('$pendingWrite = $client.WriteAsync($payload, 0, $payload.Length, $limit.Token)', [StringComparison]::Ordinal) + $writeWait = $testSource.IndexOf('$null = $pendingWrite.GetAwaiter().GetResult()', [StringComparison]::Ordinal) + Assert-CapTest ($posted -ge 0 -and $write -gt $posted -and $writeWait -gt $write) ` + 'Owned same-process pipe must post its reader before waiting for writer completion.' + Assert-CapTest ([regex]::Matches($testSource, '\[Threading\.CancellationTokenSource\]::new\(5000\)').Count -eq 1 -and + -not $testSource.Contains('CancelAfter(') -and -not $testSource.Contains('Start-Sleep') -and + $testSource.Contains('$primary.Data[''CapTestDetail''] = [pscustomobject]$observation') -and + $testSource.Contains('beforePid = $null; afterPid = $null') -and + $testSource.Contains('$observation.payloadBytes = $offset') -and + $testSource.Contains("@{ name = 'read'; task = `$pendingRead }") -and + $testSource.Contains("@{ name = 'write'; task = `$pendingWrite }")) ` + 'Owned pipe case must preserve one original deadline, partial observations and pending-task cleanup.' return @{ declarations = 1; nativeCalls = 0; processControlCalls = 0 } } @@ -7708,7 +7734,14 @@ if ($NativePipePeerContractOnly) { $server = $null $client = $null $waiting = $null + $pendingRead = $null + $pendingWrite = $null $primary = $null + $observation = [ordered]@{ + stage = 'starting'; connected = $false; beforePid = $null; afterPid = $null + readPosted = $false; writePosted = $false; writeCompleted = $false + payloadBytes = 0; payloadMatched = $null; deadlineMilliseconds = 5000 + } try { $server = [IO.Pipes.NamedPipeServerStream]::new($pipeName, [IO.Pipes.PipeDirection]::InOut, 1, [IO.Pipes.PipeTransmissionMode]::Byte, @@ -7716,42 +7749,72 @@ if ($NativePipePeerContractOnly) { $client = [IO.Pipes.NamedPipeClientStream]::new('.', $pipeName, [IO.Pipes.PipeDirection]::InOut, [IO.Pipes.PipeOptions]::Asynchronous) $waiting = $server.WaitForConnectionAsync($limit.Token) + $observation.stage = 'connecting' $null = $client.ConnectAsync($limit.Token).GetAwaiter().GetResult() $null = $waiting.GetAwaiter().GetResult() + $observation.connected = $true + $observation.stage = 'query-before' $before = [CapNative]::PipeServerProcessId($client.SafePipeHandle) + $observation.beforePid = $before Assert-CapTest ($before -gt 0 -and $before -eq [Environment]::ProcessId) ` 'Kernel peer differs from the known owned same-process server.' $payload = [byte[]]@(11, 22, 33) - $null = $client.WriteAsync($payload, 0, $payload.Length, $limit.Token).GetAwaiter().GetResult() $read = [byte[]]::new($payload.Length) + # Neither pipe endpoint may wait for its peer before that peer has posted I/O. + $observation.stage = 'posting-read' + $pendingRead = $server.ReadAsync($read, 0, $read.Length, $limit.Token) + $observation.readPosted = $true + $observation.stage = 'posting-write' + $pendingWrite = $client.WriteAsync($payload, 0, $payload.Length, $limit.Token) + $observation.writePosted = $true $offset = 0 while ($offset -lt $read.Length) { - $count = $server.ReadAsync($read, $offset, ($read.Length - $offset), $limit.Token).GetAwaiter().GetResult() + $observation.stage = 'waiting-read' + $count = $pendingRead.GetAwaiter().GetResult() + $pendingRead = $null Assert-CapTest ($count -gt 0) 'Owned pipe ended before its complete payload.' $offset += $count + $observation.payloadBytes = $offset + if ($offset -lt $read.Length) { + $pendingRead = $server.ReadAsync($read, $offset, ($read.Length - $offset), $limit.Token) + } } + $observation.stage = 'waiting-write' + $null = $pendingWrite.GetAwaiter().GetResult() + $observation.writeCompleted = $true + $observation.stage = 'query-after' $after = [CapNative]::PipeServerProcessId($client.SafePipeHandle) + $observation.afterPid = $after + $observation.payloadMatched = ($read -join ',') -ceq ($payload -join ',') Assert-CapTest ($after -eq $before -and ($read -join ',') -ceq ($payload -join ',')) ` 'Kernel peer or actual owned pipe payload changed during the live connection.' - return @{ before = $before; after = $after; payloadBytes = $offset + $observation.stage = 'complete' + return @{ before = $before; after = $after; payloadBytes = $offset; observation = $observation scope = 'ACTUAL_OBSERVER_CONNECTION_ONLY_NOT_ANOTHER_APP_CONNECTION' } } catch { $primary = $_.Exception + $primary.Data['CapTestDetail'] = [pscustomobject]$observation throw } finally { $limit.Cancel() if ($null -ne $client) { $client.Dispose() } if ($null -ne $server) { $server.Dispose() } try { - if ($null -ne $waiting) { - try { $null = $waiting.GetAwaiter().GetResult() } catch { + foreach ($entry in @( + @{ name = 'connection'; task = $waiting }, + @{ name = 'read'; task = $pendingRead }, + @{ name = 'write'; task = $pendingWrite } + )) { + if ($null -ne $entry.task) { + try { $null = $entry.task.GetAwaiter().GetResult() } catch { $cause = $_.Exception while ($cause.InnerException) { $cause = $cause.InnerException } if ($cause -isnot [OperationCanceledException] -or -not $limit.IsCancellationRequested) { if ($null -eq $primary) { throw } - $primary.Data['CapPipeWaitCleanupFailure'] = $cause.ToString() + $primary.Data["CapPipeCleanupFailure.$($entry.name)"] = $cause.ToString() } } + } } } finally { $limit.Dispose() } } From ecbd55be7c56b13e2445445b9d78b3402fe953ec Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sun, 8 Nov 2026 09:49:40 -0700 Subject: [PATCH 09/12] Document native workspace creation controls Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index ad4f3378..85dc42e7 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -585,6 +585,14 @@ app instance using a child-only `GRAPHCODE_SUPPORT_DIR` environment. The child does not inherit `GRAPHCODE_DAEMON_PIPE`: it derives its daemon endpoint from the new support directory rather than reusing the parent's explicit override. The parent's environment and other inherited variables remain unchanged. +The native New Workspace sheet shows the resolved directory, a separate-window +diagram titled with the typed name, and workspace-isolation details. Invalid or +colliding names stay inline with Create & Open disabled; an empty draft stays +quiet, and Cancel discards it. Ctrl+A selects the complete focused name field +without changing ordinary typing or AltGr handling. Creation and launch still +use the existing workspace lifecycle and identity guards. Windows keeps its +existing ASCII normalization and 48-character limit rather than adopting the +macOS transliteration and truncation rules. Selecting the current workspace does nothing; selecting an identified running workspace restores its exact window rather than the first GraphCode window. `Ctrl+Alt+PageUp` / `Ctrl+Alt+PageDown` and Previous/Next Workspace cycle only From 3daa3ca49fe4eee620d84e359afd5fac07bb0698 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sun, 8 Nov 2026 11:28:28 -0700 Subject: [PATCH 10/12] Preserve first ownership failure before retention hold Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/ShellProcessOwnership.psm1 | 118 +++++++++++++++-- .../Tests/ShellProcessOwnership.Tests.ps1 | 121 ++++++++++++++++++ Tools/windows/validation-matrix.md | 12 ++ 3 files changed, 243 insertions(+), 8 deletions(-) diff --git a/Tools/windows/ShellProcessOwnership.psm1 b/Tools/windows/ShellProcessOwnership.psm1 index 6417a547..eca3c953 100644 --- a/Tools/windows/ShellProcessOwnership.psm1 +++ b/Tools/windows/ShellProcessOwnership.psm1 @@ -70,10 +70,111 @@ function Write-ShellOwnershipDiagnostic([Exception] $Exception, [string] $Messag } } -function Set-ShellOwnershipUnproven([Exception] $Exception, [string[]] $RetainedRoots = @()) { +function Write-ShellOwnershipFirstFailure([Exception] $Exception, + [Management.Automation.ErrorRecord] $FailureRecord = $null, + [Management.Automation.ErrorRecord] $SecondaryCleanupFailureRecord = $null) { + if (-not [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT")) { + throw "First-failure diagnostic root is unavailable" + } + $configured = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") + if (-not $configured -or -not [IO.Path]::IsPathFullyQualified($configured)) { + throw "First-failure diagnostic root is not an absolute directory" + } + $root = [IO.Path]::GetFullPath($configured) + $directory = Get-Item -LiteralPath $root -Force -ErrorAction Stop + if (-not $directory.PSIsContainer) { throw "First-failure diagnostic root is not a directory" } + while ($directory) { + if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "First-failure diagnostic root has a reparse ancestor" + } + $directory = $directory.Parent + } + $chain = [Collections.Generic.List[object]]::new() + $pending=[Collections.Generic.Queue[object]]::new() + $pending.Enqueue(@{exception=$Exception;path='primary'}) + if($SecondaryCleanupFailureRecord){ + $pending.Enqueue(@{exception=$SecondaryCleanupFailureRecord.Exception;path='secondaryCleanup'}) + } + $innerOmitted=$false + while($pending.Count -gt 0 -and $chain.Count -lt 16) { + $entry=$pending.Dequeue() + $current=$entry.exception + $operands=[ordered]@{} + foreach($key in @("NativeErrorCode", "NativeResult", "FailedOwnershipPredicate", "CallSite")){ + if($current.Data.Contains($key)){ + $value=$current.Data[$key] + if($null -eq $value -or $value -is [string] -or $value -is [bool] -or + $value -is [int] -or $value -is [long] -or $value -is [uint32]){ + $operands[$key]=$value + } + } + } + $chain.Add([ordered]@{ + path=$entry.path;type = $current.GetType().FullName; message = $current.Message + stackTrace = $current.StackTrace + exceptionHResult = $current.HResult + availableFailureOperands=$operands + nativeErrorAvailable = ($current -is [ComponentModel.Win32Exception]) + nativeErrorCode = $(if ($current -is [ComponentModel.Win32Exception]) { $current.NativeErrorCode } else { $null }) + }) + if($current -is [AggregateException]){ + for($index=0;$index -lt $current.InnerExceptions.Count;$index++){ + if($pending.Count+$chain.Count -ge 16){$innerOmitted=$true;break} + $pending.Enqueue(@{exception=$current.InnerExceptions[$index];path="$($entry.path).$index"}) + } + }elseif($current.InnerException){ + if($pending.Count+$chain.Count -lt 16){ + $pending.Enqueue(@{exception=$current.InnerException;path="$($entry.path).inner"}) + }else{$innerOmitted=$true} + } + } + $available=$chain[0].availableFailureOperands + $payload=[ordered]@{ + exceptionType=$Exception.GetType().FullName;exceptionMessage=$Exception.Message + errorRecordAvailable=($null -ne $FailureRecord) + errorRecordType=$(if($FailureRecord){$FailureRecord.GetType().FullName}else{$null}) + fullyQualifiedErrorId=$(if($FailureRecord){$FailureRecord.FullyQualifiedErrorId}else{$null}) + scriptStackTrace=$(if($FailureRecord){$FailureRecord.ScriptStackTrace}else{$null}) + positionMessage=$(if($FailureRecord -and $FailureRecord.InvocationInfo){$FailureRecord.InvocationInfo.PositionMessage}else{$null}) + primaryRecordExceptionType=$(if($FailureRecord){$FailureRecord.Exception.GetType().FullName}else{$null}) + primaryRecordExceptionMessage=$(if($FailureRecord){$FailureRecord.Exception.Message}else{$null}) + secondaryCleanupAvailable=($null -ne $SecondaryCleanupFailureRecord) + secondaryCleanup=$(if($SecondaryCleanupFailureRecord){[ordered]@{ + label='ACTUAL_SECONDARY_CLEANUP_ERROR_NOT_PRIMARY' + errorRecordType=$SecondaryCleanupFailureRecord.GetType().FullName + exceptionType=$SecondaryCleanupFailureRecord.Exception.GetType().FullName + exceptionMessage=$SecondaryCleanupFailureRecord.Exception.Message + scriptStackTrace=$SecondaryCleanupFailureRecord.ScriptStackTrace + fullyQualifiedErrorId=$SecondaryCleanupFailureRecord.FullyQualifiedErrorId + positionMessage=$(if($SecondaryCleanupFailureRecord.InvocationInfo){$SecondaryCleanupFailureRecord.InvocationInfo.PositionMessage}else{$null}) + }}else{$null}) + exceptionChain=$chain.ToArray();innerChainTruncated=($innerOmitted -or $pending.Count -gt 0) + availableFailureOperands=$available + ownershipPredicateAvailable=$available.Contains("FailedOwnershipPredicate") + nativeResultAvailable=$available.Contains("NativeResult") + diagnosis="AVAILABLE_ORIGINAL_ERROR_ONLY_NO_NEW_NATIVE_PROBE_OR_IDENTITY_INFERENCE" + } + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($payload|ConvertTo-Json -Depth 8)) + if($bytes.Length -gt 65536){throw "First-failure projection exceeds 65536 bytes; original ErrorRecord remains in memory"} + $stream=[IO.File]::Open((Join-Path $root "ownership-first-failure.json"), + [IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} +} + +function Set-ShellOwnershipUnproven([Exception] $Exception, [string[]] $RetainedRoots = @(), + [Management.Automation.ErrorRecord] $FailureRecord = $null, + [Management.Automation.ErrorRecord] $SecondaryCleanupFailureRecord = $null) { $Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true $roots = @($RetainedRoots | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) }) $Exception.Data["GraphCodeValidationRetainedRoots"] = $roots + if($FailureRecord -and -not $Exception.Data.Contains("GraphCodeValidationOriginalFailureRecord")){ + $Exception.Data["GraphCodeValidationOriginalFailureRecord"]=$FailureRecord + } + if($SecondaryCleanupFailureRecord -and -not $Exception.Data.Contains("GraphCodeValidationOriginalCleanupFailureRecord")){ + $Exception.Data["GraphCodeValidationOriginalCleanupFailureRecord"]=$SecondaryCleanupFailureRecord + } + try{Write-ShellOwnershipFirstFailure $Exception $FailureRecord $SecondaryCleanupFailureRecord} + catch{$Exception.Data["GraphCodeValidationFirstFailureDiagnosticFailure"]=$_} Write-ShellOwnershipDiagnostic $Exception "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" if ([Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT")) { $stream = $null @@ -122,7 +223,7 @@ function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartIn if ($Scope.Closed) { throw "Owned shell scope is closed" } try { $run = New-ShellSuspendedLaunch $StartInfo } catch { if (Test-ShellOwnershipUnproven $_) { - throw (Set-ShellOwnershipUnproven $_.Exception) + throw (Set-ShellOwnershipUnproven $_.Exception -FailureRecord $_) } throw } @@ -144,10 +245,11 @@ function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartIn $primary = $_ try { $run.Stop() } catch { throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( - "Owned launch failed: $($primary.Exception.Message); OWNED_CLEANUP_UNPROVEN: $($_.Exception.Message)", $primary.Exception))) + "Owned launch failed: $($primary.Exception.Message); OWNED_CLEANUP_UNPROVEN: $($_.Exception.Message)", $primary.Exception)) ` + -FailureRecord $primary -SecondaryCleanupFailureRecord $_) } if (Test-ShellOwnershipUnproven $primary) { - throw (Set-ShellOwnershipUnproven $primary.Exception) + throw (Set-ShellOwnershipUnproven $primary.Exception -FailureRecord $primary) } throw $primary } @@ -165,7 +267,7 @@ function Get-ShellOwnedMember($Scope, [int] $ProcessId) { return $process } catch { if ($process) { $process.Dispose() } - throw (Set-ShellOwnershipUnproven $_.Exception) + throw (Set-ShellOwnershipUnproven $_.Exception -FailureRecord $_) } } @@ -204,7 +306,7 @@ function Receive-ShellOwnedOutput($Scope, [Diagnostics.Process] $Process, Error = $runs[0].TakeError($MaximumCharacters) } } catch { - throw (Set-ShellOwnershipUnproven $_.Exception) + throw (Set-ShellOwnershipUnproven $_.Exception -FailureRecord $_) } } @@ -226,7 +328,7 @@ function Complete-ShellLaunch($Run) { } $Run.Finalized = $true } catch { - throw (Set-ShellOwnershipUnproven $_.Exception) + throw (Set-ShellOwnershipUnproven $_.Exception -FailureRecord $_) } } @@ -267,7 +369,7 @@ function Close-ShellProcessScope($Scope) { $Scope.Closed = $true Write-Host "SHELL_OWNED_JOB_CLEANUP=verified active=0 launches=$($Scope.Runs.Count)" } catch { - throw (Set-ShellOwnershipUnproven $_.Exception) + throw (Set-ShellOwnershipUnproven $_.Exception -FailureRecord $_) } } diff --git a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 index 86f6352e..810ca65c 100644 --- a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 +++ b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 @@ -110,6 +110,36 @@ Describe "Owned shell scope mock-only lifecycle" { $script:scope.Runs.Count | Should -Be 0 $script:events | Should -Not -Contain "Assign" } + It "retains the original assignment and cleanup records at the actual Start Stop catch" -Tag "FirstFailureDiagnostic" { + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + $env:GRAPHCODE_VALIDATION_ROOT = $root + try { + $script:assignFailure = [ComponentModel.Win32Exception]::new(5, "primary assignment") + $script:assignFailure.Data["NativeResult"] = [uint32]11 + $script:stopFailure = [ComponentModel.Win32Exception]::new(6, "secondary stop") + $script:stopFailure.Data["NativeResult"] = [uint32]22 + Mock Invoke-ShellJob { throw $script:assignFailure } -ParameterFilter { $Operation -eq "Assign" } + $script:run | Add-Member ScriptMethod Stop { throw $script:stopFailure } -Force + $failure = $null + try { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } catch { $failure = $_ } + $failure.Exception | Should -BeOfType ([InvalidOperationException]) + [object]::ReferenceEquals($failure.Exception.InnerException, $script:assignFailure) | Should -BeTrue + $evidence = Get-Content (Join-Path $root "ownership-first-failure.json") -Raw | ConvertFrom-Json + $first = @($evidence.exceptionChain | Where-Object { $_.path -like "primary*" -and $_.nativeErrorAvailable -and $_.nativeErrorCode -eq 5 }) + $second = @($evidence.exceptionChain | Where-Object { $_.path -like "secondaryCleanup*" -and $_.nativeErrorAvailable -and $_.nativeErrorCode -eq 6 }) + $first.Count | Should -Be 1 + $second.Count | Should -Be 1 + $first[0].availableFailureOperands.NativeResult | Should -Be 11 + $second[0].availableFailureOperands.NativeResult | Should -Be 22 + $evidence.scriptStackTrace | Should -Not -BeExactly $evidence.secondaryCleanup.scriptStackTrace + $evidence.secondaryCleanupAvailable | Should -BeTrue + $script:scope.Runs.Count | Should -Be 1 + Should -Invoke Invoke-ShellJob -Times 1 -Exactly -ParameterFilter { $Operation -eq "Assign" } + } finally { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + } + } It "refuses a newly reopened or stale PID object" { $script:scope.Runs.Add($script:run) $other = [Diagnostics.Process]::new() @@ -990,4 +1020,95 @@ Describe "Ownership-unproven root retention contract (filesystem only)" { $inner.Data["GraphCodeValidationRootRetentionRequired"] = "true" Test-ShellOwnershipUnproven $outer | Should -BeFalse } + It "flushes first-failure evidence before the sentinel while preserving the actual ErrorRecord" -Tag "FirstFailureDiagnostic" { + $exception = [ComponentModel.Win32Exception]::new(5, "controlled native refusal") + $exception.Data["NativeResult"] = [uint32]4294967295 + try { throw $exception } catch { $original = $_ } + Mock Write-Host { + Test-Path (Join-Path $env:GRAPHCODE_VALIDATION_ROOT "ownership-first-failure.json") | Should -BeTrue + } -ModuleName ShellProcessOwnership -ParameterFilter { + $Object -eq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + } + $returned = Set-ShellOwnershipUnproven $exception -FailureRecord $original + [object]::ReferenceEquals($returned, $exception) | Should -BeTrue + [object]::ReferenceEquals($returned.Data["GraphCodeValidationOriginalFailureRecord"], $original) | Should -BeTrue + $evidence = Get-Content (Join-Path $root "ownership-first-failure.json") -Raw | ConvertFrom-Json + $evidence.errorRecordAvailable | Should -BeTrue + $evidence.exceptionChain[0].nativeErrorCode | Should -Be 5 + $evidence.availableFailureOperands.NativeResult | Should -Be 4294967295 + $evidence.scriptStackTrace | Should -BeExactly $original.ScriptStackTrace + (Get-Content (Join-Path $root "ownership-unproven.json") -Raw | ConvertFrom-Json).reason | Should -BeExactly "ownership_unproven" + } + It "never overwrites the immutable first failure or replaces its primary on write refusal" -Tag "FirstFailureDiagnostic" { + $path = Join-Path $root "ownership-first-failure.json" + [IO.File]::WriteAllText($path, "original failure evidence") + $before = (Get-FileHash $path).Hash + $primary = [InvalidOperationException]::new("keep this primary") + try { throw $primary } catch { $original = $_ } + $returned = Set-ShellOwnershipUnproven $primary -FailureRecord $original + [object]::ReferenceEquals($returned, $primary) | Should -BeTrue + (Get-FileHash $path).Hash | Should -BeExactly $before + $returned.Data["GraphCodeValidationFirstFailureDiagnosticFailure"] | Should -BeOfType ([Management.Automation.ErrorRecord]) + Test-Path (Join-Path $root "ownership-unproven.json") | Should -BeTrue + Test-ShellOwnershipUnproven $returned | Should -BeTrue + } + It "reports absent records and native operands as unavailable without invented zeros" -Tag "FirstFailureDiagnostic" { + $returned = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("no native result")) + $evidence = Get-Content (Join-Path $root "ownership-first-failure.json") -Raw | ConvertFrom-Json + $evidence.errorRecordAvailable | Should -BeFalse + $evidence.scriptStackTrace | Should -BeNullOrEmpty + $evidence.nativeResultAvailable | Should -BeFalse + $evidence.ownershipPredicateAvailable | Should -BeFalse + $evidence.exceptionChain[0].nativeErrorCode | Should -BeNullOrEmpty + $evidence.secondaryCleanupAvailable | Should -BeFalse + } + It "preserves all available aggregate branches and signed COM HRESULTs" -Tag "FirstFailureDiagnostic" { + $primary = [AggregateException]::new("both branches", [Exception[]]@( + [InvalidOperationException]::new("primary branch"), + [Runtime.InteropServices.COMException]::new("cleanup branch", -2147467259))) + $returned = Set-ShellOwnershipUnproven $primary + [object]::ReferenceEquals($returned, $primary) | Should -BeTrue + $evidence = Get-Content (Join-Path $root "ownership-first-failure.json") -Raw | ConvertFrom-Json + $evidence.exceptionChain.Count | Should -Be 3 + $evidence.exceptionChain[1].message | Should -BeExactly "primary branch" + $evidence.exceptionChain[2].message | Should -BeExactly "cleanup branch" + $evidence.exceptionChain[2].exceptionHResult | Should -Be -2147467259 + $evidence.exceptionChain[2].nativeErrorAvailable | Should -BeFalse + $evidence.innerChainTruncated | Should -BeFalse + } + It "preserves distinct primary and secondary cleanup records, stacks and native results" -Tag "FirstFailureDiagnostic" { + function Invoke-PrimaryFailure { throw [ComponentModel.Win32Exception]::new(5, "primary assignment") } + function Invoke-CleanupFailure { throw [ComponentModel.Win32Exception]::new(6, "secondary stop") } + try { Invoke-PrimaryFailure } catch { $original = $_ } + try { Invoke-CleanupFailure } catch { $cleanup = $_ } + $original.Exception.Data["NativeResult"] = [uint32]11 + $cleanup.Exception.Data["NativeResult"] = [uint32]22 + $primary = [InvalidOperationException]::new("unchanged wrapper", $original.Exception) + $returned = Set-ShellOwnershipUnproven $primary -FailureRecord $original -SecondaryCleanupFailureRecord $cleanup + [object]::ReferenceEquals($returned, $primary) | Should -BeTrue + [object]::ReferenceEquals($returned.InnerException, $original.Exception) | Should -BeTrue + [object]::ReferenceEquals($returned.Data["GraphCodeValidationOriginalCleanupFailureRecord"], $cleanup) | Should -BeTrue + $evidence = Get-Content (Join-Path $root "ownership-first-failure.json") -Raw | ConvertFrom-Json + $evidence.primaryRecordExceptionMessage | Should -BeExactly "primary assignment" + $evidence.secondaryCleanup.exceptionMessage | Should -BeExactly "secondary stop" + $evidence.secondaryCleanup.label | Should -BeExactly "ACTUAL_SECONDARY_CLEANUP_ERROR_NOT_PRIMARY" + $evidence.scriptStackTrace | Should -Not -BeExactly $evidence.secondaryCleanup.scriptStackTrace + $first = @($evidence.exceptionChain | Where-Object path -CEQ "primary.inner") + $second = @($evidence.exceptionChain | Where-Object path -CEQ "secondaryCleanup") + $first.Count | Should -Be 1 + $second.Count | Should -Be 1 + $first[0].nativeErrorCode | Should -Be 5 + $second[0].nativeErrorCode | Should -Be 6 + $first[0].availableFailureOperands.NativeResult | Should -Be 11 + $second[0].availableFailureOperands.NativeResult | Should -Be 22 + } + It "refuses oversized projection without replacing the primary or suppressing retention" -Tag "FirstFailureDiagnostic" { + $primary = [InvalidOperationException]::new(("x" * 70000)) + $returned = Set-ShellOwnershipUnproven $primary + [object]::ReferenceEquals($returned, $primary) | Should -BeTrue + $returned.Data["GraphCodeValidationFirstFailureDiagnosticFailure"] | Should -Not -BeNullOrEmpty + Test-Path (Join-Path $root "ownership-first-failure.json") | Should -BeFalse + Test-Path (Join-Path $root "ownership-unproven.json") | Should -BeTrue + Test-ShellOwnershipUnproven $returned | Should -BeTrue + } } diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index c5084b1e..349fbe40 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -585,6 +585,18 @@ by the runner into the Boolean exception tag, including terminating or incorrectly zero-exit child results. Receipt or sentinel intent never authorizes process termination. +Before that sentinel is emitted, shell ownership failures attempt a separate +CreateNew, flushed UTF-8 `ownership-first-failure.json` in the same validated +root. It records scalar primary ErrorRecord details, exception stacks and +signed HRESULTs, plus explicitly labeled secondary cleanup evidence when the +launch and its cleanup both failed. The projection is limited to 16 exception +branches and 64 KiB; omitted branches and unavailable native operands are +explicit, never reconstructed from PIDs or default values. The original +ErrorRecords remain attached to the same exception in memory. Projection or +write failure is attached without suppressing the independent retention +receipt, sentinel or lifetime hold. This diagnostic does not recover lost +authority, establish cleanup, or authorize another run. + ### UIA input and process ownership The gate's direct shell and stub launches use the existing assigned-job From 13114fd79f2aecc7a59f841bfa7260eaa13fe716 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sun, 8 Nov 2026 12:03:08 -0700 Subject: [PATCH 11/12] Register exact ownership diagnostic coverage Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/ShellProcessOwnership.Tests.ps1 | 42 +++++++++++++++++++ .../Adapters/LegacyOwnership.Contracts.ps1 | 29 ++++++++++++- .../ValidationRunner/Manifests/host.json | 11 ++++- Tools/windows/validation-matrix.md | 4 ++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 index 810ca65c..61eca7cb 100644 --- a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 +++ b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 @@ -1038,6 +1038,48 @@ Describe "Ownership-unproven root retention contract (filesystem only)" { $evidence.availableFailureOperands.NativeResult | Should -Be 4294967295 $evidence.scriptStackTrace | Should -BeExactly $original.ScriptStackTrace (Get-Content (Join-Path $root "ownership-unproven.json") -Raw | ConvertFrom-Json).reason | Should -BeExactly "ownership_unproven" + $registry = Get-Content (Join-Path $repo "Tools\windows\ValidationRunner\Manifests\host.json") -Raw | ConvertFrom-Json + $required = @($registry.units | Where-Object id -CEQ "host.legacy-ownership.shell-scope") + $required.Count | Should -Be 1 + $required[0].minimumCount | Should -Be 77 + $required[0].exactCount | Should -Be 77 + @($required[0].requiredGroups).Count | Should -Be 7 + $adapter = Join-Path $repo "Tools\windows\ValidationRunner\Adapters\LegacyOwnership.Contracts.ps1" + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($adapter, [ref]$tokens, [ref]$errors) + $helpers = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Get-ShellScopeDiagnosticGroups" + }, $true)) + $errors.Count | Should -Be 0 + $helpers.Count | Should -Be 1 + . ([scriptblock]::Create($helpers[0].Extent.Text)) + $cases = @($required[0].requiredGroups | ForEach-Object { + [pscustomobject]@{ Name = $_; Tag = @("FirstFailureDiagnostic"); Executed = $true; Result = "Passed" } + }) + $run = [pscustomobject]@{ TotalCount = 77; Tests = $cases } + $groups = @(Get-ShellScopeDiagnosticGroups $run $required[0]) + $groups.Count | Should -Be 7 + foreach ($mutation in @("missing", "extra", "duplicate", "failed", "unexecuted", "wrong-count", "duplicate-required")) { + $invalidCases = @($cases | ForEach-Object { + [pscustomobject]@{ Name = $_.Name; Tag = $_.Tag; Executed = $_.Executed; Result = $_.Result } + }) + $expected = [pscustomobject]@{ minimumCount = 77; exactCount = 77; requiredGroups = @($required[0].requiredGroups) } + $total = 77 + switch ($mutation) { + "missing" { $invalidCases = $invalidCases[1..6] } + "extra" { $invalidCases += [pscustomobject]@{ Name = "unexpected diagnostic"; Tag = @("FirstFailureDiagnostic"); Executed = $true; Result = "Passed" } } + "duplicate" { $invalidCases += $invalidCases[0] } + "failed" { $invalidCases[0].Result = "Failed" } + "unexecuted" { $invalidCases[0].Executed = $false } + "wrong-count" { $total = 70 } + "duplicate-required" { $expected.requiredGroups += $expected.requiredGroups[0] } + } + $invalidRun = [pscustomobject]@{ TotalCount = $total; Tests = $invalidCases } + { Get-ShellScopeDiagnosticGroups $invalidRun $expected } | Should -Throw + } } It "never overwrites the immutable first failure or replaces its primary on write refusal" -Tag "FirstFailureDiagnostic" { $path = Join-Path $root "ownership-first-failure.json" diff --git a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 index a5ac2d5b..9d160fe2 100644 --- a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 @@ -5,6 +5,23 @@ param( [Parameter(Mandatory)][ValidateSet("shell-scope", "live-adapter", "live-cleanup", "sdk-prerequisites", "compiled-environment")][string] $Contract ) $ErrorActionPreference = "Stop" +function Get-ShellScopeDiagnosticGroups($Run, $Required) { + $expected = @($Required.requiredGroups) + if ($expected.Count -ne 7 -or @($expected | Sort-Object -Unique).Count -ne $expected.Count -or + $Required.minimumCount -ne 70 + $expected.Count -or $Required.exactCount -ne $Required.minimumCount -or + $Run.TotalCount -ne $Required.exactCount) { + throw "Shell ownership registry must retain 70 existing cases and exactly seven distinct diagnostic cases" + } + $diagnostics = @($Run.Tests | Where-Object { $_.Tag -ccontains "FirstFailureDiagnostic" }) + if ($diagnostics.Count -ne $expected.Count) { throw "Shell ownership diagnostic case count differs" } + foreach ($name in $expected) { + $matching = @($diagnostics | Where-Object { $_.Name -ceq $name }) + if ($matching.Count -ne 1 -or $matching[0].Executed -ne $true -or $matching[0].Result -cne "Passed") { + throw "Shell ownership diagnostic case did not execute and pass exactly once: $name" + } + } + return $expected +} $tests = Join-Path $RepositoryRoot "Tools\windows\Tests" $caseRoot = Join-Path $ArtifactRoot ("legacy-mock-" + [guid]::NewGuid().ToString("N")) [void][IO.Directory]::CreateDirectory($caseRoot) @@ -12,6 +29,7 @@ $previous = [Console]::Out $capturedConsole = [IO.StringWriter]::new() $output = @() $run = $null +$groups = @() try { # Legacy suites inject mocked ownership diagnostics. Keep those signals in # their evidence rather than treating them as a native owner's lifetime signal. @@ -58,6 +76,12 @@ if ($Contract -ceq "live-cleanup") { throw "Original ownership Pester suite failed or omitted controls; retained evidence: $caseRoot" } $count = $run.TotalCount + if ($Contract -ceq "shell-scope") { + $registry = Get-Content (Join-Path $RepositoryRoot "Tools\windows\ValidationRunner\Manifests\host.json") -Raw | ConvertFrom-Json + $required = @($registry.units | Where-Object id -CEQ "host.legacy-ownership.shell-scope") + if ($required.Count -ne 1) { throw "Shell ownership registry identity is absent or duplicated" } + $groups = @(Get-ShellScopeDiagnosticGroups $run $required[0]) + } } $id = if ($Contract -ceq "sdk-prerequisites") { "host.sdk-prerequisites" } ` elseif ($Contract -ceq "compiled-environment") { "host.compiled-assembly-environment" } ` @@ -66,9 +90,12 @@ $description = if ($Contract -ceq "sdk-prerequisites") { "SDK prerequisites: actual installed-SDK probe plus controlled bootstrap/dependency cases" } elseif ($Contract -ceq "compiled-environment") { "Actual scoped caller publication, supplied/empty/missing state, failure identity and native child environment controls" -} else { "Actual original $Contract mock-only suite" } +} elseif ($Contract -ceq "shell-scope") { + "Actual original shell-scope suite: mock/filesystem contracts and controlled native suspended-launch fixtures" +} else { "Actual original $Contract suite" } $row = [ordered]@{ id = $id; status = "Passed"; executedCount = $count evidence = "$description; positive count and zero failed/skipped/not-run; retained detailed evidence: $caseRoot" } +if ($Contract -ceq "shell-scope") { $row.groups = $groups } Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 994e2e3b..83bfec9f 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -3,7 +3,16 @@ { "id": "host.sdk-prerequisites", "owner": "Host", "minimumCount": 49, "exactCount": 49 }, { "id": "host.admission-guards", "owner": "Host", "minimumCount": 65, "exactCount": 65 }, { "id": "host.compiled-assembly-environment", "owner": "Host", "minimumCount": 7, "exactCount": 7 }, - { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 70, "exactCount": 70 }, + { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 77, "exactCount": 77, + "requiredGroups": [ + "retains the original assignment and cleanup records at the actual Start Stop catch", + "flushes first-failure evidence before the sentinel while preserving the actual ErrorRecord", + "never overwrites the immutable first failure or replaces its primary on write refusal", + "reports absent records and native operands as unavailable without invented zeros", + "preserves all available aggregate branches and signed COM HRESULTs", + "preserves distinct primary and secondary cleanup records, stacks and native results", + "refuses oversized projection without replacing the primary or suppressing retention" + ] }, { "id": "host.legacy-ownership.live-adapter", "owner": "Host", "minimumCount": 13, "exactCount": 13 }, { "id": "host.legacy-ownership.live-cleanup", "owner": "Host", "minimumCount": 84, "exactCount": 84 }, { "id": "host.provenance.current-source", "owner": "Host", "minimumCount": 1 }, diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 349fbe40..ef95066e 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -596,6 +596,10 @@ ErrorRecords remain attached to the same exception in memory. Projection or write failure is attached without suppressing the independent retention receipt, sentinel or lifetime hold. This diagnostic does not recover lost authority, establish cleanup, or authorize another run. +The canonical shell ownership unit retains its 70 existing cases and requires +the seven named first-failure diagnostic cases exactly once (77 total). Its +unfiltered Pester payload includes controlled native launch fixtures as well +as mock/filesystem contracts; filtered headless results are not that full gate. ### UIA input and process ownership From e1d0d9f8dcf8a86389ad793e287af61a121751d8 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sun, 8 Nov 2026 12:48:58 -0700 Subject: [PATCH 12/12] Validate ownership diagnostic identities in shell unit entrypoint Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/ShellProcessOwnership.Tests.ps1 | 55 +++++++++++++++++++ Tools/windows/Tests/WindowsShell.Tests.ps1 | 25 +++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 index 61eca7cb..7cf345bf 100644 --- a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 +++ b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 @@ -1080,6 +1080,61 @@ Describe "Ownership-unproven root retention contract (filesystem only)" { $invalidRun = [pscustomobject]@{ TotalCount = $total; Tests = $invalidCases } { Get-ShellScopeDiagnosticGroups $invalidRun $expected } | Should -Throw } + $unitTokens = $null + $unitErrors = $null + $unitAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repo "Tools\windows\Tests\WindowsShell.Tests.ps1"), [ref]$unitTokens, [ref]$unitErrors) + $entrypoints = @($unitAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq "Invoke-Native" -and + $node.CommandElements.Count -eq 3 -and + $node.CommandElements[1] -is [Management.Automation.Language.StringConstantExpressionAst] -and + $node.CommandElements[1].Value -ceq "Shell process ownership mock contracts" + }, $true)) + $unitErrors.Count | Should -Be 0 + $entrypoints.Count | Should -Be 1 + $bodyText = $entrypoints[0].CommandElements[2].ScriptBlock.Extent.Text + $body = [scriptblock]::Create('param([string]$PSScriptRoot)' + "`n" + + $bodyText.Substring(1, $bodyText.Length - 2)) + $unitRoot = Join-Path $repo "Tools\windows\Tests" + $repoRoot = $repo + $script:controlledOwnershipRun = [pscustomobject]@{ + TotalCount = 77; PassedCount = 77; FailedCount = 0; SkippedCount = 0; NotRunCount = 0; Tests = $cases + } + Mock Invoke-Pester { $script:controlledOwnershipRun } + Mock Write-Host {} + # Negative control from the failing 131 unit entrypoint, not another live count contract. + $oldBody = [scriptblock]::Create('param([string]$PSScriptRoot)' + "`n" + @' +. (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") +$ownershipResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "ShellProcessOwnership.Tests.ps1") -PassThru +if ($ownershipResults.TotalCount -ne 70 -or + $ownershipResults.PassedCount -ne $ownershipResults.TotalCount -or + $ownershipResults.FailedCount -ne 0 -or $ownershipResults.SkippedCount -ne 0) { + throw "Shell process ownership mock contracts did not pass all 70 unskipped cases" +} +'@) + { & $oldBody $unitRoot } | Should -Throw "*all 70 unskipped cases*" + & $body $unitRoot + foreach ($mutation in @("missing", "extra", "duplicate", "unexecuted", "wrong-count", "not-run")) { + $invalidCases = @($cases | ForEach-Object { + [pscustomobject]@{ Name = $_.Name; Tag = $_.Tag; Executed = $_.Executed; Result = $_.Result } + }) + $total = 77 + $notRun = 0 + switch ($mutation) { + "missing" { $invalidCases = $invalidCases[1..6] } + "extra" { $invalidCases += [pscustomobject]@{ Name = "unexpected diagnostic"; Tag = @("FirstFailureDiagnostic"); Executed = $true; Result = "Passed" } } + "duplicate" { $invalidCases += $invalidCases[0] } + "unexecuted" { $invalidCases[0].Executed = $false } + "wrong-count" { $total = 70 } + "not-run" { $notRun = 1 } + } + $script:controlledOwnershipRun = [pscustomobject]@{ + TotalCount = $total; PassedCount = $total; FailedCount = 0; SkippedCount = 0; NotRunCount = $notRun; Tests = $invalidCases + } + { & $body $unitRoot } | Should -Throw + } } It "never overwrites the immutable first failure or replaces its primary on write refusal" -Tag "FirstFailureDiagnostic" { $path = Join-Path $root "ownership-first-failure.json" diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index e5e671e8..afccd011 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -519,13 +519,30 @@ function Resolve-TestZig { Invoke-Native "Shell process ownership mock contracts" { . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") + $registry = Get-Content (Join-Path $repoRoot "Tools\windows\ValidationRunner\Manifests\host.json") -Raw | ConvertFrom-Json + $required = @($registry.units | Where-Object id -CEQ "host.legacy-ownership.shell-scope") + if ($required.Count -ne 1) { throw "Shell ownership registry identity is absent or duplicated" } + $tokens = $null + $errors = $null + $adapter = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repoRoot "Tools\windows\ValidationRunner\Adapters\LegacyOwnership.Contracts.ps1"), + [ref]$tokens, [ref]$errors) + $definitions = @($adapter.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Get-ShellScopeDiagnosticGroups" + }, $true)) + if ($errors.Count -ne 0 -or $definitions.Count -ne 1) { throw "Exact shell ownership diagnostic validator unavailable" } + . ([scriptblock]::Create($definitions[0].Extent.Text)) $ownershipResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "ShellProcessOwnership.Tests.ps1") -PassThru - if ($ownershipResults.TotalCount -ne 70 -or + if ($ownershipResults.TotalCount -le 0 -or $ownershipResults.PassedCount -ne $ownershipResults.TotalCount -or - $ownershipResults.FailedCount -ne 0 -or $ownershipResults.SkippedCount -ne 0) { - throw "Shell process ownership mock contracts did not pass all 70 unskipped cases" + $ownershipResults.FailedCount -ne 0 -or $ownershipResults.SkippedCount -ne 0 -or + $ownershipResults.NotRunCount -ne 0) { + throw "Shell process ownership contracts did not pass all executed, unskipped cases" } - Write-Host "SHELL_PROCESS_OWNERSHIP_MOCK_CASES=$($ownershipResults.TotalCount)" + $groups = @(Get-ShellScopeDiagnosticGroups $ownershipResults $required[0]) + Write-Host "SHELL_PROCESS_OWNERSHIP_CASES=$($ownershipResults.TotalCount) DIAGNOSTICS=$($groups.Count) INCLUDES_CONTROLLED_NATIVE_FIXTURES" $global:LASTEXITCODE = 0 }