Skip to content
Merged
16 changes: 12 additions & 4 deletions docs/sandbox-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,19 @@ that fact and exits successfully. To start one, use `winapp run . --on sandbox -
The report distinguishes what the guest supports from what the current client can do;
a minimized client can prevent input or capture even when the guest supports both.
Use the guest window list for UI PIDs, not a deployment's tracked launcher process.
The JSON `workRoot` field (shown as `Work root` in text output) is the absolute base
for relative file-transfer paths, normally `C:\WinApp\work`. It is separate from
`capabilities.managedRoot`, normally `C:\WinApp`, and is omitted when the guest
does not report its managed root.
If several client windows prevent an unambiguous capture, the error lists candidates;
decide which to close before retrying.

## Running commands and copying files

```powershell
winapp target exec sandbox -- dotnet --info
winapp target push sandbox .\setup.ps1 Setup\setup.ps1
winapp target exec sandbox --cwd C:\WinApp\work\Setup -- powershell -ExecutionPolicy Bypass -File .\setup.ps1
$copy = winapp target push sandbox .\setup.ps1 Setup\setup.ps1 --json | ConvertFrom-Json
winapp target exec sandbox --cwd (Split-Path -Parent $copy.targetPath) -- powershell -ExecutionPolicy Bypass -File .\setup.ps1
winapp target pull sandbox Results .\results
```

Expand All @@ -284,10 +288,14 @@ standard streams, and returns the command's exit code. It is not a full interact
terminal; console applications see redirected pipes. `--json` formats winapp errors,
not the child command's stdout.

For `push` and `pull`, **target paths are relative to `C:\WinApp\work`**. Absolute,
For `push` and `pull`, **target paths are relative to the `workRoot` reported by
[`target snapshot`](#inspecting-the-sandbox)**. Absolute,
rooted, and UNC target paths are refused. A single file lands at exactly the destination
you name; a directory preserves its structure beneath that destination. Use the
resolved guest path printed after a push as the next command's `--cwd`.
resolved guest path printed after a push (JSON `targetPath`) to choose the next
command's `--cwd`; for a single file, use its parent directory. If the guest does
not report its managed root, push fails before copying; follow the error's update
guidance rather than assuming a default path.

Only run setup scripts you trust. The example uses process-scoped
`-ExecutionPolicy Bypass` because a fresh Sandbox normally refuses scripts under its
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -1679,7 +1679,7 @@ winapp target push sandbox .\setup.ps1 Setup\setup.ps1
winapp target pull sandbox Results .\results
```

Target paths are relative to `C:\WinApp\work`; absolute, rooted, and UNC target paths
Target paths are relative to the target's managed work area; absolute, rooted, and UNC target paths
are rejected. A file destination includes its filename. See
[Running commands and copying files](sandbox-execution.md#running-commands-and-copying-files)
for directory layout, link handling, and running a copied script.
Expand Down
10 changes: 6 additions & 4 deletions plugins/winapp/skills/winapp-sandbox/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,20 @@ winapp target record sandbox --duration-sec 20 --frames -o .\sandbox.mp4
## Guest setup and file transfer

```powershell
winapp target push sandbox .\setup.ps1 Setup\setup.ps1
winapp target exec sandbox --cwd C:\WinApp\work\Setup -- powershell -ExecutionPolicy Bypass -File .\setup.ps1
$copy = winapp target push sandbox .\setup.ps1 Setup\setup.ps1 --json | ConvertFrom-Json
winapp target exec sandbox --cwd (Split-Path -Parent $copy.targetPath) -- powershell -ExecutionPolicy Bypass -File .\setup.ps1
winapp target pull sandbox Results .\results
```

Use `target exec` only for necessary setup or diagnostics, not instead of `winapp run`.
It streams the command's output and is not a full terminal. The example's execution-policy
override is scoped to that PowerShell process; run only a trusted script.

Push/pull target paths are **relative to `C:\WinApp\work`**; absolute, rooted, and UNC
Push/pull target paths are **relative to the target's managed work area**; absolute, rooted, and UNC
paths are rejected. A single-file destination includes the filename. Use the reported
resolved guest path for `--cwd`. Directory copies skip linked entries; directly named
resolved guest path to choose `--cwd` (its parent directory for a single file).
See [the transfer guide](https://github.com/microsoft/WinAppCli/blob/main/docs/sandbox-execution.md#running-commands-and-copying-files)
for path reporting and missing-root errors. Directory copies skip linked entries; directly named
linked sources and paths through destination links are rejected. Deployment rejects links.

## Cleanup and recovery
Expand Down
11 changes: 11 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/SandboxRunTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ public void GuestPaths_ResolvesUnderTheRootTheGuestReported()
GuestPaths.Resolve(Capabilities(), GuestPaths.PayloadScope("abc")));
}

[TestMethod]
[DataRow(@"C:\WinApp", "", @"C:\WinApp\work")]
[DataRow(@"C:\WinApp", "Setup/probe.txt", @"C:\WinApp\work\Setup\probe.txt")]
[DataRow(@"D:\Managed files", ".", @"D:\Managed files\work")]
public void GuestPaths_TransferOutputUsesTheReportedRoot(string managedRoot, string relative, string expected)
{
Assert.AreEqual(expected, TargetFileTransferService.DescribeTargetPath(
Capabilities(managedRoot: managedRoot),
TargetFileTransferService.NormalizeTargetRelative(relative)));
}

/// <summary>
/// Nested, the layout would be enumerated by the next reconciliation, found absent from the
/// host's desired state, and deleted — destroying what the previous run registered from.
Expand Down
106 changes: 106 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/TargetCaptureCommandTests.Transfer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
// Licensed under the MIT License.

using System.Text.Json;
using Spectre.Console.Testing;
using WinApp.Cli.Commands;
using WinApp.Cli.ExecutionTargets.Abstractions;
using WinApp.Cli.ExecutionTargets.GuestAgent;
using WinApp.Cli.ExecutionTargets.Orchestration;

namespace WinApp.Cli.Tests;

public partial class TargetCaptureCommandTests
{
[TestMethod]
[DataRow(true)]
[DataRow(false)]
public async Task Push_NondefaultRootReportsTheActualDestination(bool json)
{
await using var harness = new Harness(GuestWindows());
using var console = new TestConsole();
console.Profile.Width = 240;
Directory.CreateDirectory(_root);
var source = Path.Combine(_root, "probe.txt");
Comment thread
nmetulev marked this conversation as resolved.
await File.WriteAllTextAsync(source, "work-root probe", TestContext.CancellationToken);
var command = new TargetPushCommand();
var arguments = new[] { "sandbox", source, "Setup files/./probe.txt" };

Assert.AreEqual(0, await new TargetPushCommand.Handler(harness.Orchestrator, console)
.InvokeAsync(Parse(command, json ? [.. arguments, "--json"] : arguments),
TestContext.CancellationToken));

var workRoot = new GuestFileService(harness.GuestManaged)
.ResolveScopeDirectory(TargetFileTransferService.WorkScope, create: false);
var destination = Path.Combine(workRoot, "Setup files", "probe.txt");
Comment thread
nmetulev marked this conversation as resolved.
Assert.AreEqual("work-root probe",
await File.ReadAllTextAsync(destination, TestContext.CancellationToken));
if (json)
{
using var document = JsonDocument.Parse(console.Output);
Assert.AreEqual(destination, document.RootElement.GetProperty("targetPath").GetString());
}
else
{
StringAssert.Contains(console.Output, $"to {destination} on sandbox.");
}
}

[TestMethod]
public async Task Push_DirectoryToDotReportsWorkRoot()
{
await using var harness = new Harness(GuestWindows());
using var console = new TestConsole();
Directory.CreateDirectory(_root);
await File.WriteAllTextAsync(Path.Combine(_root, "probe.txt"), "work-root probe",
Comment thread
nmetulev marked this conversation as resolved.
TestContext.CancellationToken);
var command = new TargetPushCommand();

Assert.AreEqual(0, await new TargetPushCommand.Handler(harness.Orchestrator, console)
.InvokeAsync(Parse(command, "sandbox", _root, ".", "--json"),
TestContext.CancellationToken));

var workRoot = new GuestFileService(harness.GuestManaged)
.ResolveScopeDirectory(TargetFileTransferService.WorkScope, create: false);
using var document = JsonDocument.Parse(console.Output);
Assert.AreEqual(workRoot, document.RootElement.GetProperty("targetPath").GetString());
Assert.AreEqual("work-root probe",
await File.ReadAllTextAsync(Path.Combine(workRoot, "probe.txt"), TestContext.CancellationToken));
Comment thread
nmetulev marked this conversation as resolved.
}

[TestMethod]
public async Task Push_MissingManagedRootFailsBeforeWriting()
{
await using var harness = new Harness(GuestWindows());
harness.Backend.ReportsManagedRoot = false;
using var console = new TestConsole();
Directory.CreateDirectory(_root);
var source = Path.Combine(_root, "probe.txt");
Comment thread
nmetulev marked this conversation as resolved.
await File.WriteAllTextAsync(source, "work-root probe", TestContext.CancellationToken);
var command = new TargetPushCommand();

var result = await CaptureStandardErrorAsync(() =>
new TargetPushCommand.Handler(harness.Orchestrator, console).InvokeAsync(
Parse(command, "sandbox", source, "probe.txt", "--json"),
TestContext.CancellationToken));

Assert.AreNotEqual(0, result.ExitCode);
using var error = JsonDocument.Parse(result.StandardError);
Assert.AreEqual(ExecutionTargetErrorCodes.AgentIncompatible,
error.RootElement.GetProperty("error").GetProperty("code").GetString());
Assert.IsFalse(Directory.Exists(Path.Combine(harness.GuestManaged, "work")));
Comment thread
nmetulev marked this conversation as resolved.
Assert.AreEqual(string.Empty, console.Output);
}

[TestMethod]
public void Push_RootedPathAdviceDoesNotInventAGuestRoot()
{
var failure = Assert.ThrowsExactly<ExecutionTargetException>(() =>
TargetFileTransferService.NormalizeTargetRelative(@"D:\Managed\work\probe.txt"));

StringAssert.Contains(failure.Error.Message, "managed work area");
StringAssert.Contains(failure.Error.UserAction, "target snapshot");
Assert.IsFalse(failure.Error.Message.Contains(@"C:\WinApp", StringComparison.Ordinal));
Assert.IsFalse(failure.Error.UserAction!.Contains(@"C:\WinApp", StringComparison.Ordinal));
}
}
59 changes: 58 additions & 1 deletion src/winapp-CLI/WinApp.Cli.Tests/TargetCaptureCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,61 @@ public void Cleanup()

// ---- snapshot ------------------------------------------------------------------

[TestMethod]
public async Task Snapshot_WorkRootMatchesRelativeTransfersUnderTheReportedNondefaultRoot()
{
await using var harness = new Harness(GuestWindows());
using var console = new TestConsole();
var files = new GuestFileService(harness.GuestManaged);
var workRoot = files.ResolveScopeDirectory(TargetFileTransferService.WorkScope, create: false);
Comment thread
nmetulev marked this conversation as resolved.

Assert.AreEqual(0, await RunSnapshotAsync(harness, console, "sandbox", "--json"));

using var document = JsonDocument.Parse(console.Output);
Assert.AreEqual(harness.GuestManaged,
document.RootElement.GetProperty("capabilities").GetProperty("managedRoot").GetString());
Assert.IsTrue(document.RootElement.TryGetProperty("workRoot", out var reported),
"Snapshot must expose the working directory separately from managedRoot.");
Assert.AreEqual(workRoot, reported.GetString());
Assert.IsFalse(Directory.Exists(workRoot), "Reporting the work root must not create it.");
Assert.AreEqual(0, harness.Backend.EnsureCalls);
Assert.AreEqual(0, harness.Rendering.ResolveSurfaceCalls);
}

[TestMethod]
public async Task Snapshot_HumanOutputReportsWorkRoot()
{
await using var harness = new Harness(GuestWindows());
using var console = new TestConsole();
console.Profile.Width = 240;

Assert.AreEqual(0, await RunSnapshotAsync(harness, console, "sandbox"));

StringAssert.Contains(console.Output, "Work root: " +
new GuestFileService(harness.GuestManaged)
.ResolveScopeDirectory(TargetFileTransferService.WorkScope, create: false));
}

[TestMethod]
[DataRow(false, false, false)]
[DataRow(true, false, true)]
[DataRow(true, true, false)]
public async Task Snapshot_UnavailableRootDoesNotInventWorkRoot(
bool running, bool agentAnswers, bool reportsManagedRoot)
{
await using var harness = new Harness(GuestWindows());
harness.Backend.Running = running;
harness.Backend.AgentAnswers = agentAnswers;
harness.Backend.ReportsManagedRoot = reportsManagedRoot;
using var console = new TestConsole();

Assert.AreEqual(0, await RunSnapshotAsync(harness, console, "sandbox", "--json"));

using var document = JsonDocument.Parse(console.Output);
Assert.IsFalse(document.RootElement.TryGetProperty("workRoot", out _));
Assert.AreEqual(0, harness.Backend.EnsureCalls);
}

[TestMethod]
public async Task Snapshot_ReportsReadinessTheDesktopWindowAndWhatIsOnIt()
{
Expand Down Expand Up @@ -1160,6 +1215,8 @@ private class FakeBackend(
/// <summary>Whether the running target's agent answers an inspect-only attach.</summary>
public bool AgentAnswers { get; set; } = true;

public bool ReportsManagedRoot { get; set; } = true;

/// <summary>The host end of the last channel handed out, so a test can watch it close.</summary>
public IGuestTransport? LastHostTransport { get; private set; }

Expand Down Expand Up @@ -1201,7 +1258,7 @@ private TargetConnection Connect()
new ScriptedGuestWinapp(harness, stdout, exitCode, request => Requests.Add(request)),
new StaticGuestSessionProbe(new GuestSessionInfo(1, "WinSta0", HasInputDesktop: true)),
new GuestAgentIdentity("1.0.0", "hash", "arm64", 1, 1),
files: new GuestFileService(harness.GuestManaged),
files: ReportsManagedRoot ? new GuestFileService(harness.GuestManaged) : null,
guestWinapp: @"C:\WinAppGuest\winapp.exe",
appLauncher: appLauncher);

Expand Down
15 changes: 8 additions & 7 deletions src/winapp-CLI/WinApp.Cli/Commands/TargetCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Spectre.Console;
using WinApp.Cli.ExecutionTargets.Abstractions;
using WinApp.Cli.ExecutionTargets.Orchestration;
using WinApp.Cli.Helpers;

namespace WinApp.Cli.Commands;

Expand Down Expand Up @@ -403,17 +404,17 @@ public static async Task<int> RunAsync(
cancellationToken)
.ConfigureAwait(false);

var result = await TargetFileTransferService
.CopyAsync(target.Operations, request, cancellationToken)
.ConfigureAwait(false);

// Target paths resolve under a managed root, so the effective destination is stated
// rather than left for the user to infer from a path they did not type.
// Resolve before copying so a missing guest root cannot fail reporting after a write.
var resolved = direction == TargetTransferDirection.ToTarget
? TargetFileTransferService.DescribeTargetPath(
target.Capabilities,
TargetFileTransferService.NormalizeTargetRelative(request.TargetPath))
: null;

var result = await TargetFileTransferService
.CopyAsync(target.Operations, request, cancellationToken)
.ConfigureAwait(false);

if (json)
{
console.Profile.Out.Writer.WriteLine(JsonSerializer.Serialize(
Expand All @@ -430,7 +431,7 @@ public static async Task<int> RunAsync(
else if (resolved is not null)
{
console.MarkupLineInterpolated(
$"Copied {result.Transferred} file(s), skipped {result.Skipped} unchanged, to {resolved} on {reference.Selector}.");
$"Copied {result.Transferred} file(s), skipped {result.Skipped} unchanged, to {TerminalText.Sanitize(resolved)} on {reference.Selector}.");
}
else
{
Expand Down
11 changes: 11 additions & 0 deletions src/winapp-CLI/WinApp.Cli/Commands/TargetSnapshotCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ public override async Task<int> InvokeAsync(
Running = inspection.Running,
Attached = target is not null,
Capabilities = target?.Capabilities,
WorkRoot = target?.Capabilities is { } capabilities &&
!string.IsNullOrWhiteSpace(capabilities.ManagedRoot)
? GuestPaths.Resolve(capabilities, TargetFileTransferService.WorkScope)
: null,
Desktop = DescribeDesktop(inspection.Running),
Deployments = [],
};
Expand Down Expand Up @@ -383,6 +387,10 @@ private static void Render(IAnsiConsole console, ExecutionTargetRef reference, T
console.MarkupLineInterpolated($" Architecture: {TerminalText.Sanitize(capabilities.Architecture)}");
console.MarkupLineInterpolated(
$" Guest support: real input {Yes(capabilities.SupportsRealInput)}, screen capture {Yes(capabilities.SupportsScreenCapture)}, interactive desktop {Yes(capabilities.SupportsInteractiveDesktop)}");
if (output.WorkRoot is { } workRoot)
{
console.MarkupLineInterpolated($" Work root: {TerminalText.Sanitize(workRoot)}");
}
}
else
{
Expand Down Expand Up @@ -491,6 +499,9 @@ internal sealed class TargetSnapshotOutput
/// <summary>What the live guest reports it can do, or null when no agent answered.</summary>
public ExecutionTargetCapabilities? Capabilities { get; init; }

/// <summary>Absolute base for relative transfer paths, or null when the guest root is unknown.</summary>
public string? WorkRoot { get; init; }

/// <summary>Where this target's desktop is drawn on this machine, if anywhere.</summary>
public required TargetSnapshotDesktop Desktop { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public static string Resolve(ExecutionTargetCapabilities capabilities, GuestPath
{
throw ExecutionTargetException.Create(
ExecutionTargetErrorCodes.AgentIncompatible,
"The guest agent did not report where it stores deployed applications.",
"The guest agent did not report where it stores managed files.",
userAction: "Update your winapp installation. If the guest is still incompatible, save any guest work and close Windows Sandbox before retrying.");
}

Expand Down
Loading
Loading