Skip to content

Commit 884176c

Browse files
samples(demo-script-tool): address PR #134 review feedback
- StepFileWriter: refuse model-emitted relative paths that escape the step directory (path traversal via "..", absolute paths, drive letters). Resolves and verifies each target is strictly inside stepDir before writing — model output is untrusted, not a sandbox. - StepFileWriter: fall back to the first .cs file when multi-file mode doesn't emit Program.cs, so OutputPath always points at a real file. Without this, alternate entry-point names left primary as the directory and downstream File.Exists checks silently no-op'd (canonicalize-on-write skipped, Open Folder couldn't restore step.Code). - IModelClient + CopilotSdkClient + GithubModelsClient: new ResetAsync method so a long-lived SDK client (CopilotSdkClient caches a started CopilotClient) can be torn down after gh auth refresh. Pipeline calls it in both auth-retry paths — without it, refreshing gh auth was a no-op because the CopilotClient session was authenticated under the old token. GithubModelsClient implements as no-op (it reads the token per-call already). - DemoScriptParser: drop section-0 preamble content rather than capturing it into RawTail. The serializer always writes RawTail at the end, so capturing preamble silently moved any text between H1 and the first H2 below the steps section on the next save. - README: rewrite the auth section to describe the actual Copilot SDK / Copilot CLI flow (gh auth + Copilot-enabled account) instead of the obsolete GITHUB_TOKEN / models:read scope description. Refs: copilot-pull-request-reviewer comments on PR #134.
1 parent 5ae28f5 commit 884176c

7 files changed

Lines changed: 153 additions & 14 deletions

File tree

samples/apps/demo-script-tool/App/Services/CopilotSdkClient.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,41 @@ public async Task<string> DescribeAvailableModelsAsync(CancellationToken ct)
231231
}
232232
}
233233

234+
/// <summary>
235+
/// Tear down the cached <see cref="CopilotClient"/> so the next
236+
/// <see cref="StreamAsync"/> call re-runs <see cref="GetClientAsync"/>
237+
/// and starts a fresh CLI session. The pipeline calls this after
238+
/// <c>gh auth refresh</c> — without it the SDK keeps using the
239+
/// already-started session that was authenticated under the old token,
240+
/// and the auth-retry path silently fails again.
241+
/// </summary>
242+
public async Task ResetAsync(CancellationToken ct)
243+
{
244+
await _initLock.WaitAsync(ct).ConfigureAwait(false);
245+
try
246+
{
247+
if (_client is { } client)
248+
{
249+
System.Diagnostics.Debug.WriteLine("[CopilotSdk] resetting CLI client (auth refresh)");
250+
try { await client.StopAsync().ConfigureAwait(false); }
251+
catch (System.Exception ex)
252+
{
253+
System.Diagnostics.Debug.WriteLine($"[CopilotSdk] StopAsync during reset threw: {ex.Message}");
254+
}
255+
try { await client.DisposeAsync().ConfigureAwait(false); }
256+
catch (System.Exception ex)
257+
{
258+
System.Diagnostics.Debug.WriteLine($"[CopilotSdk] DisposeAsync during reset threw: {ex.Message}");
259+
}
260+
_client = null;
261+
}
262+
}
263+
finally
264+
{
265+
_initLock.Release();
266+
}
267+
}
268+
234269
public async ValueTask DisposeAsync()
235270
{
236271
if (_client is { } client)

samples/apps/demo-script-tool/App/Services/DemoScriptParser.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,15 @@ void FlushCurrentStep()
8383
switch (section)
8484
{
8585
case 0:
86-
// Pre-section content (between H1 and the first H2). Empty lines OK.
87-
// Anything substantial is captured into RawTail to keep round-trips honest.
88-
if (!string.IsNullOrWhiteSpace(raw))
89-
rawTail.AppendLine(raw);
86+
// Pre-section content between the H1 title and the first H2.
87+
// Capturing into rawTail would round-trip incorrectly: the
88+
// serializer always writes rawTail at the end of the file,
89+
// which would silently move preamble below the steps section
90+
// on the next save. The format spec doesn't reserve space for
91+
// a preamble, so we drop section-0 content rather than
92+
// reorder it. (If a real round-trip need shows up, switch
93+
// to a separate RawHeader field that the serializer emits
94+
// immediately after the H1.)
9095
break;
9196

9297
case 1:

samples/apps/demo-script-tool/App/Services/GenerationPipeline.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ async Task<bool> StreamSingleStepWithAuthRetryAsync(DemoScriptModel model, strin
171171
System.Diagnostics.Debug.WriteLine($"[Pipeline] AuthExpired on step {step.Number}, retrying after re-auth: {ex.Message}");
172172
_status.SetGeneratingStatus("Re-authenticating with GitHub…");
173173
await _auth.EnsureAuthenticatedAsync(ct).ConfigureAwait(false);
174+
// Drop any cached client state (e.g. CopilotSdkClient's long-lived
175+
// CopilotClient session) so the retry actually picks up the new
176+
// gh auth credentials. Without this, refreshing gh auth state
177+
// changes nothing the next StreamAsync call can see.
178+
await _client.ResetAsync(ct).ConfigureAwait(false);
174179
_status.SetGeneratingStatus($"Generating step {step.Number} of {model.Steps.Count}…");
175180
return await StreamSingleStepAsync(model, projectRoot, step, prior, ct).ConfigureAwait(false);
176181
}
@@ -376,6 +381,12 @@ async Task<bool> ApplyFixAttemptAsync(StepModel step, DemoScriptModel model, str
376381
catch (AuthExpiredException)
377382
{
378383
await _auth.EnsureAuthenticatedAsync(ct).ConfigureAwait(false);
384+
// Same reason as in the streaming-retry path above: refreshing gh
385+
// auth alone doesn't help an SDK that cached a long-lived session.
386+
// We still return false here (the outer build-and-fix loop will
387+
// count this as a consumed attempt), but at least the NEXT fix
388+
// attempt will start from a fresh client.
389+
await _client.ResetAsync(ct).ConfigureAwait(false);
379390
return false;
380391
}
381392

samples/apps/demo-script-tool/App/Services/GithubModelsClient.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,14 @@ public async IAsyncEnumerable<string> StreamAsync(
131131
return null;
132132
}
133133

134+
/// <summary>
135+
/// No-op: this client reads the GitHub token via <see cref="GhAuth.GetTokenAsync"/>
136+
/// at the start of every <see cref="StreamAsync"/> call, so a fresh
137+
/// <c>gh auth refresh</c> is picked up automatically. The interface
138+
/// requires the method for SDK-backed clients that snapshot credentials
139+
/// (see <see cref="CopilotSdkClient.ResetAsync"/>).
140+
/// </summary>
141+
public Task ResetAsync(CancellationToken ct) => Task.CompletedTask;
142+
134143
public void Dispose() => _http.Dispose();
135144
}

samples/apps/demo-script-tool/App/Services/IModelClient.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Generic;
22
using System.Threading;
3+
using System.Threading.Tasks;
34

45
namespace DemoScriptTool.App.Services;
56

@@ -22,6 +23,17 @@ public interface IModelClient
2223
/// the underlying HTTP read when <paramref name="ct"/> is cancelled.
2324
/// </summary>
2425
IAsyncEnumerable<string> StreamAsync(string systemPrompt, string userPrompt, CancellationToken ct);
26+
27+
/// <summary>
28+
/// Drop any cached auth state so the next <see cref="StreamAsync"/> call
29+
/// re-authenticates. Called by the pipeline after a successful
30+
/// <c>gh auth refresh</c> so an SDK that snapshots its credentials at
31+
/// startup (e.g. <see cref="CopilotSdkClient"/>'s long-lived
32+
/// <c>CopilotClient</c>) actually picks up the new ones. Implementations
33+
/// that read the token per-call (<see cref="GithubModelsClient"/>) can
34+
/// no-op.
35+
/// </summary>
36+
Task ResetAsync(CancellationToken ct);
2537
}
2638

2739
/// <summary>Thrown when the GitHub Models API rejects the call as unauthenticated.</summary>

samples/apps/demo-script-tool/App/Services/StepFileWriter.cs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ public string Write(int stepNumber, IReadOnlyDictionary<string, string> files, s
4444
// Multi-file mode: write each file under step-NN/ verbatim.
4545
var stepDir = Path.Combine(projectRoot, $"step-{stepNumber:D2}");
4646
Directory.CreateDirectory(stepDir);
47+
// Used to validate every model-supplied path stays inside stepDir —
48+
// see comment on TryResolveContainedPath. The trailing separator on
49+
// the canonical form is what makes the StartsWith check below safe
50+
// against the "stepDir" / "stepDir-evil" prefix-collision case.
51+
var stepDirCanonical = Path.GetFullPath(stepDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
4752

4853
string? primary = null;
54+
string? firstCsFile = null;
4955
bool sawCsproj = false;
5056

5157
foreach (var kv in files)
@@ -56,14 +62,25 @@ public string Write(int stepNumber, IReadOnlyDictionary<string, string> files, s
5662
if (rel.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase))
5763
rel = rel[prefix.Length..];
5864

59-
var target = Path.Combine(stepDir, rel);
65+
// SECURITY: model output is untrusted. A path like "../../foo" or an
66+
// absolute path joined with stepDir would let a generated step
67+
// overwrite arbitrary files under the user's workspace. Reject any
68+
// path that, once resolved, doesn't sit strictly inside stepDir.
69+
if (!TryResolveContainedPath(stepDirCanonical, rel, out var target))
70+
{
71+
System.Diagnostics.Debug.WriteLine($"[StepFileWriter] rejecting unsafe path '{kv.Key}' → outside step dir");
72+
continue;
73+
}
74+
6075
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
6176
WriteWithRetry(target, kv.Value);
6277

6378
if (rel.EndsWith(".csproj", System.StringComparison.OrdinalIgnoreCase))
6479
sawCsproj = true;
6580
if (rel.EndsWith("Program.cs", System.StringComparison.OrdinalIgnoreCase))
6681
primary ??= target;
82+
else if (firstCsFile is null && rel.EndsWith(".cs", System.StringComparison.OrdinalIgnoreCase))
83+
firstCsFile = target;
6784
}
6885

6986
if (!sawCsproj)
@@ -72,7 +89,45 @@ public string Write(int stepNumber, IReadOnlyDictionary<string, string> files, s
7289
WriteWithRetry(fallbackCsproj, ScaffoldCsproj());
7390
}
7491

75-
return primary ?? stepDir;
92+
// Prefer Program.cs as the entry point (it's what the system prompt
93+
// asks for), but fall back to the first .cs we wrote so the canonical
94+
// OutputPath points at a real file. Without this fallback, when the
95+
// model picks an alternate name (App.cs, Demo.cs) the step's
96+
// OutputPath would be the directory and downstream
97+
// `File.Exists(primary)` checks all silently no-op — the canonical
98+
// step.Code never gets refreshed from disk and Open Folder can't
99+
// restore the file's content on relaunch.
100+
return primary ?? firstCsFile ?? stepDir;
101+
}
102+
103+
/// <summary>
104+
/// Resolve <paramref name="rel"/> relative to <paramref name="stepDirCanonical"/>
105+
/// and verify it stays inside the step directory. Returns false for paths
106+
/// that would escape (<c>..</c> traversal, absolute paths, junctions, etc.)
107+
/// so the caller can refuse to write them.
108+
/// </summary>
109+
static bool TryResolveContainedPath(string stepDirCanonical, string rel, out string target)
110+
{
111+
target = string.Empty;
112+
if (string.IsNullOrEmpty(rel)) return false;
113+
// Path.IsPathRooted catches absolute paths AND paths with drive letters
114+
// ("C:\foo") — both should be refused even though Combine would happily
115+
// discard stepDir and use the rooted side as-is.
116+
if (Path.IsPathRooted(rel)) return false;
117+
118+
try
119+
{
120+
var combined = Path.GetFullPath(Path.Combine(stepDirCanonical, rel));
121+
if (!combined.StartsWith(stepDirCanonical, System.StringComparison.OrdinalIgnoreCase))
122+
return false;
123+
target = combined;
124+
return true;
125+
}
126+
catch (System.Exception ex)
127+
{
128+
System.Diagnostics.Debug.WriteLine($"[StepFileWriter] path resolve failed for '{rel}': {ex.Message}");
129+
return false;
130+
}
76131
}
77132

78133
/// <summary>

samples/apps/demo-script-tool/README.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Demo Script Tool
22

33
A WinUI 3 desktop sample app built on Reactor. Author a coding demo as a
4-
single Markdown file (`demo-script.md`) and let the GitHub Models AI SDK
4+
single Markdown file (`demo-script.md`) and let the GitHub Copilot SDK
55
generate runnable .NET code for each step plus speaker notes you can paste
66
into your slides.
77

@@ -29,15 +29,27 @@ dotnet run --project samples/apps/demo-script-tool/App/DemoScriptTool.csproj --
2929
(Reactor's own `--devtools` flags pass straight through; the first
3030
non-flag argument is treated as the folder path.)
3131

32-
On first launch the app will look up your GitHub token via:
32+
### Authentication
3333

34-
1. `$env:GITHUB_TOKEN` (set this for non-interactive use), or
35-
2. `gh auth token` (the GitHub CLI's cache), or
36-
3. A spawned `gh auth login --web --scopes models:read` flow, which opens an
37-
interactive console window — this is the recommended route.
34+
The app uses the [`GitHub.Copilot.SDK`](https://www.nuget.org/packages/GitHub.Copilot.SDK)
35+
NuGet package, which talks to a bundled Copilot CLI. That CLI piggybacks on
36+
whatever account `gh auth` currently considers active, so:
3837

39-
The token is read at the moment of each generation call; it is not cached
40-
inside the app.
38+
1. Make sure you have the [GitHub CLI](https://cli.github.com/) installed and
39+
on `PATH`.
40+
2. Sign in with a Copilot-enabled account: `gh auth login --web`.
41+
3. Confirm with `gh auth status` — the active account needs to have GitHub
42+
Copilot Pro / Pro+ / Business / Enterprise so the SDK can issue Copilot
43+
chat completions on your behalf.
44+
45+
The default model is `claude-sonnet-4.5`; change it by passing a different
46+
id to the `CopilotSdkClient` constructor in `DemoScriptShell`. Use the
47+
**⚡ Devtools → Log available Copilot models…** menu item to see what model
48+
ids your account can use.
49+
50+
If a generation call hits an authentication failure mid-stream, the pipeline
51+
runs `gh auth login` (in a spawned console) and resets the cached Copilot
52+
SDK client so the retry picks up the refreshed credentials.
4153

4254
## 2. Author a demo
4355

0 commit comments

Comments
 (0)