Skip to content

Commit aea3733

Browse files
Fix intro podcast permissions, streaming, and RSS boundaries
Bound RSS network operations, reject external XML resources in Java, preserve namespaced metadata and .NET streaming, and support SDK permission payloads without blocking Rust runtime shutdown. Add cross-language regressions and preserve the offline Java test workflow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 079affc commit aea3733

17 files changed

Lines changed: 469 additions & 32 deletions

‎scripts/validate_workshop.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,6 +1911,9 @@ def validate_intro_workshop() -> None:
19111911
f"{language} hello world must follow the source guide's four edits")
19121912
require(re.findall(r"^### (\d+)\.", podcast_act, re.MULTILINE) == ["1", "2", "3"],
19131913
f"{language} podcast act must follow the source guide's three edits")
1914+
if language == "dotnet":
1915+
require("Streaming = true" in podcast_act,
1916+
".NET podcast configuration must preserve streaming for its delta event handler")
19141917
require("Replace" in hello_act and INTRO_ENTRYPOINTS[language].replace("/", "\\") in read(guide),
19151918
f"{language} demo must edit its included entrypoint")
19161919
require("LIVE_DEMO.md" in read(starter / "README.md"),

‎start-intro/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,9 @@ one subscription instead of printing each text fragment twice. Node.js lets
5050
the bounded send propagate session errors instead of throwing from a callback.
5151
Its RSS helper uses parser-based XML decoding and HTML-to-plain-text extraction,
5252
with regression tests for CDATA, entity decoding, and script/style exclusion.
53+
All six RSS helpers use finite ten-second network timeouts. Java rejects XML
54+
DOCTYPE declarations and external resources while reading namespaced duration
55+
metadata. Python and Rust recognize the SDK's custom-tool permission payloads;
56+
Rust reads approvals on a detached input thread so a turn timeout can still
57+
shut down the runtime.
5358
The upstream repository is attribution, not a setup requirement.

‎start-intro/dotnet/LIVE_DEMO.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Replace the hello-world `SessionConfig` fields with these, keeping
133133

134134
```csharp
135135
Model = model,
136+
Streaming = true,
136137
Tools = [episodeTool, latestEpisodesTool],
137138
AvailableTools = ["get_github_podcast_episode", "get_latest_github_podcast_episodes"],
138139
OnPermissionRequest = PermissionPrompt.RequestAsync,

‎start-intro/dotnet/Tools/GitHubPodcastEpisodeTool.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ private static async Task<EpisodeBrief> GetAsync(string? episodeTitle)
5656

5757
private static async Task<List<XElement>> GetItemsAsync()
5858
{
59-
using var httpClient = new HttpClient();
59+
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
6060
var feed = await httpClient.GetStringAsync(FeedUrl);
6161
var document = XDocument.Parse(feed);
6262
return document.Descendants("item").ToList();

‎start-intro/go/helpers.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import (
1212
"strconv"
1313
"strings"
1414
"sync/atomic"
15+
"time"
1516

1617
copilot "github.com/github/copilot-sdk/go"
1718
)
1819

1920
const feedURL = "https://feeds.simplecast.com/ioCY0vfY"
2021

22+
var podcastHTTPClient = &http.Client{Timeout: 10 * time.Second}
23+
2124
type episodeBrief struct {
2225
EpisodeNumber *int `json:"episodeNumber"`
2326
Title string `json:"title"`
@@ -99,7 +102,7 @@ func getEpisode(episodeTitle string) (episodeBrief, error) {
99102
}
100103

101104
func getItems() ([]rssItem, error) {
102-
response, err := http.Get(feedURL)
105+
response, err := podcastHTTPClient.Get(feedURL)
103106
if err != nil {
104107
return nil, err
105108
}

‎start-intro/go/helpers_test.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"net"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"testing"
10+
"time"
11+
)
12+
13+
type feedTransport struct {
14+
target *url.URL
15+
}
16+
17+
func (transport feedTransport) RoundTrip(request *http.Request) (*http.Response, error) {
18+
redirected := request.Clone(request.Context())
19+
redirected.URL = transport.target
20+
return http.DefaultTransport.RoundTrip(redirected)
21+
}
22+
23+
func TestPodcastFeedDeadline(t *testing.T) {
24+
if podcastHTTPClient.Timeout != 10*time.Second {
25+
t.Fatalf("RSS timeout must be ten seconds, got %v", podcastHTTPClient.Timeout)
26+
}
27+
original := podcastHTTPClient
28+
t.Cleanup(func() { podcastHTTPClient = original })
29+
for _, sendHeaders := range []bool{false, true} {
30+
name := "stalled headers"
31+
if sendHeaders {
32+
name = "stalled body"
33+
}
34+
t.Run(name, func(t *testing.T) {
35+
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
36+
if sendHeaders {
37+
writer.WriteHeader(http.StatusOK)
38+
writer.(http.Flusher).Flush()
39+
}
40+
select {
41+
case <-request.Context().Done():
42+
case <-time.After(2 * time.Second):
43+
}
44+
}))
45+
defer server.Close()
46+
target, err := url.Parse(server.URL)
47+
if err != nil {
48+
t.Fatal(err)
49+
}
50+
client := *original
51+
client.Timeout = 100 * time.Millisecond
52+
client.Transport = feedTransport{target: target}
53+
podcastHTTPClient = &client
54+
start := time.Now()
55+
_, err = getItems()
56+
var timeout net.Error
57+
if !errors.As(err, &timeout) || !timeout.Timeout() {
58+
t.Fatalf("Expected a timeout, got %v", err)
59+
}
60+
if elapsed := time.Since(start); elapsed > time.Second {
61+
t.Fatalf("RSS request exceeded its deadline: %v", elapsed)
62+
}
63+
})
64+
}
65+
}

‎start-intro/java/pom.xml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,22 @@
99
<properties>
1010
<maven.compiler.release>17</maven.compiler.release>
1111
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
12+
<maven.surefire.version>3.2.5</maven.surefire.version>
1213
</properties>
1314
<dependencies>
15+
<dependency>
16+
<groupId>junit</groupId>
17+
<artifactId>junit</artifactId>
18+
<version>4.13.2</version>
19+
<scope>test</scope>
20+
</dependency>
21+
<!-- Restore the dynamically selected test provider before CI's offline test run. -->
22+
<dependency>
23+
<groupId>org.apache.maven.surefire</groupId>
24+
<artifactId>surefire-junit4</artifactId>
25+
<version>${maven.surefire.version}</version>
26+
<scope>test</scope>
27+
</dependency>
1428
<dependency>
1529
<groupId>com.github</groupId>
1630
<artifactId>copilot-sdk-java</artifactId>
@@ -19,6 +33,11 @@
1933
</dependencies>
2034
<build>
2135
<plugins>
36+
<plugin>
37+
<groupId>org.apache.maven.plugins</groupId>
38+
<artifactId>maven-surefire-plugin</artifactId>
39+
<version>${maven.surefire.version}</version>
40+
</plugin>
2241
<plugin>
2342
<groupId>org.apache.maven.plugins</groupId>
2443
<artifactId>maven-compiler-plugin</artifactId>

‎start-intro/java/src/main/java/demo/GitHubPodcastEpisodeTool.java‎

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@
44
import com.github.copilot.tool.Param;
55

66
import javax.xml.parsers.DocumentBuilderFactory;
7+
import javax.xml.XMLConstants;
8+
import java.io.ByteArrayInputStream;
9+
import java.io.IOException;
710
import java.net.URI;
811
import java.net.http.HttpClient;
912
import java.net.http.HttpRequest;
1013
import java.net.http.HttpResponse;
14+
import java.time.Duration;
1115
import java.util.ArrayList;
1216
import java.util.List;
17+
import java.util.concurrent.TimeUnit;
18+
import java.util.concurrent.TimeoutException;
1319

1420
public final class GitHubPodcastEpisodeTool {
1521
private static final String FEED_URL = "https://feeds.simplecast.com/ioCY0vfY";
22+
private static final Duration FEED_TIMEOUT = Duration.ofSeconds(10);
23+
private static final HttpClient FEED_CLIENT = HttpClient.newBuilder()
24+
.connectTimeout(FEED_TIMEOUT).build();
1625

1726
private GitHubPodcastEpisodeTool() {
1827
}
@@ -76,10 +85,41 @@ private static String getEpisodeJson(String episodeTitle) {
7685
}
7786

7887
private static List<org.w3c.dom.Element> items() throws Exception {
79-
var response = HttpClient.newHttpClient().send(
80-
HttpRequest.newBuilder(URI.create(FEED_URL)).build(),
81-
HttpResponse.BodyHandlers.ofInputStream());
82-
var document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body());
88+
return readItems(FEED_CLIENT, URI.create(FEED_URL), FEED_TIMEOUT);
89+
}
90+
91+
static List<org.w3c.dom.Element> readItems(HttpClient client, URI uri, Duration timeout) throws Exception {
92+
var request = HttpRequest.newBuilder(uri).timeout(timeout).build();
93+
var pending = client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray());
94+
HttpResponse<byte[]> response;
95+
try {
96+
response = pending.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
97+
} catch (TimeoutException exception) {
98+
throw new IOException("The GitHub Podcast RSS request timed out.", exception);
99+
} catch (InterruptedException exception) {
100+
Thread.currentThread().interrupt();
101+
throw exception;
102+
} finally {
103+
pending.cancel(true);
104+
}
105+
if (response.statusCode() < 200 || response.statusCode() >= 300) {
106+
throw new IOException("Failed to fetch The GitHub Podcast RSS feed: " + response.statusCode());
107+
}
108+
return parseItems(response.body());
109+
}
110+
111+
static List<org.w3c.dom.Element> parseItems(byte[] xml) throws Exception {
112+
var factory = DocumentBuilderFactory.newInstance();
113+
factory.setNamespaceAware(true);
114+
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
115+
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
116+
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
117+
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
118+
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
119+
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
120+
factory.setXIncludeAware(false);
121+
factory.setExpandEntityReferences(false);
122+
var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
83123
var nodes = document.getElementsByTagName("item");
84124
var items = new ArrayList<org.w3c.dom.Element>();
85125
for (int index = 0; index < nodes.getLength(); index++) {
@@ -88,7 +128,7 @@ private static List<org.w3c.dom.Element> items() throws Exception {
88128
return items;
89129
}
90130

91-
private static EpisodeBrief toEpisodeBrief(org.w3c.dom.Element item) {
131+
static EpisodeBrief toEpisodeBrief(org.w3c.dom.Element item) {
92132
var title = value(item, "title");
93133
return new EpisodeBrief(
94134
episodeNumber(title),
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package demo;
2+
3+
import com.sun.net.httpserver.HttpServer;
4+
import org.junit.Test;
5+
import org.xml.sax.SAXParseException;
6+
7+
import java.io.IOException;
8+
import java.net.InetSocketAddress;
9+
import java.net.URI;
10+
import java.net.http.HttpClient;
11+
import java.net.http.HttpTimeoutException;
12+
import java.nio.charset.StandardCharsets;
13+
import java.time.Duration;
14+
import java.util.concurrent.CountDownLatch;
15+
import java.util.concurrent.TimeUnit;
16+
17+
import static org.junit.Assert.*;
18+
19+
public class GitHubPodcastEpisodeToolTest {
20+
private static final String RSS = """
21+
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"><channel><item>
22+
<title>42: GitHub &amp; Copilot</title>
23+
<itunes:duration>00:30:00</itunes:duration>
24+
<description><![CDATA[<p>Grounded facts</p>]]></description>
25+
</item></channel></rss>
26+
""";
27+
28+
@Test
29+
public void readsNamespacedDuration() throws Exception {
30+
var items = GitHubPodcastEpisodeTool.parseItems(RSS.getBytes(StandardCharsets.UTF_8));
31+
var episode = GitHubPodcastEpisodeTool.toEpisodeBrief(items.get(0));
32+
assertEquals(Integer.valueOf(42), episode.episodeNumber());
33+
assertEquals("42: GitHub & Copilot", episode.title());
34+
assertEquals("00:30:00", episode.duration());
35+
assertEquals("Grounded facts", episode.description());
36+
}
37+
38+
@Test
39+
public void rejectsInternalAndExternalDoctypes() {
40+
for (var declaration : new String[]{
41+
"<!DOCTYPE rss [<!ENTITY unsafe 'expanded content'>]>",
42+
"<!DOCTYPE rss [<!ENTITY unsafe SYSTEM 'file:///must-not-be-read'>]>",
43+
"<!DOCTYPE rss SYSTEM 'https://example.invalid/must-not-be-requested'>"
44+
}) {
45+
var xml = declaration + "<rss><channel><item><title>&unsafe;</title></item></channel></rss>";
46+
assertThrows(SAXParseException.class,
47+
() -> GitHubPodcastEpisodeTool.parseItems(xml.getBytes(StandardCharsets.UTF_8)));
48+
}
49+
}
50+
51+
@Test(timeout = 10000)
52+
public void boundsBothHeadersAndBodyReads() throws Exception {
53+
for (var sendHeaders : new boolean[]{false, true}) {
54+
var release = new CountDownLatch(1);
55+
var headersSent = new CountDownLatch(1);
56+
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
57+
server.createContext("/", exchange -> {
58+
try {
59+
if (sendHeaders) {
60+
exchange.sendResponseHeaders(200, 0);
61+
exchange.getResponseBody().flush();
62+
headersSent.countDown();
63+
}
64+
release.await(2, TimeUnit.SECONDS);
65+
if (!sendHeaders) {
66+
exchange.sendResponseHeaders(200, 0);
67+
}
68+
exchange.getResponseBody().write(RSS.getBytes(StandardCharsets.UTF_8));
69+
} catch (InterruptedException exception) {
70+
Thread.currentThread().interrupt();
71+
throw new IOException(exception);
72+
} finally {
73+
exchange.close();
74+
}
75+
});
76+
server.start();
77+
try {
78+
var uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/");
79+
var start = System.nanoTime();
80+
var failure = assertThrows(Exception.class, () -> GitHubPodcastEpisodeTool.readItems(
81+
HttpClient.newHttpClient(), uri, Duration.ofMillis(300)));
82+
assertTrue("Expected a request deadline failure",
83+
failure instanceof IOException && failure.getMessage().contains("timed out")
84+
|| failure.getCause() instanceof HttpTimeoutException);
85+
assertTrue("The full RSS body must be deadline-bounded",
86+
System.nanoTime() - start < TimeUnit.MILLISECONDS.toNanos(1500));
87+
if (sendHeaders) {
88+
assertEquals("The body-stall fixture must send headers", 0, headersSent.getCount());
89+
}
90+
} finally {
91+
release.countDown();
92+
server.stop(0);
93+
}
94+
}
95+
}
96+
}

‎start-intro/nodejs/src/github-podcast-tools.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export async function pickEpisode(episodes: EpisodeBrief[]): Promise<EpisodeBrie
6161
}
6262

6363
async function getItems(): Promise<string[]> {
64-
const response = await fetch(feedUrl);
64+
const response = await fetch(feedUrl, { signal: AbortSignal.timeout(10_000) });
6565
if (!response.ok) {
6666
throw new Error(`Failed to fetch The GitHub Podcast RSS feed: ${response.status}`);
6767
}

0 commit comments

Comments
 (0)