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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/smoke-docker-sbx.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .github/workflows/smoke-docker-sbx.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ name: Smoke Docker Sbx
engine:
id: copilot
version: 1.0.34
model: claude-sonnet-5
network:
allowed:
- defaults
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/test-gvisor-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ permissions:
contents: read

jobs:
bounded-query-isolation:
name: Bounded-query gVisor isolation
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gVisor
run: |
set -euo pipefail
ARCH=$(uname -m)
URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}"
wget -q "${URL}/runsc" "${URL}/containerd-shim-runsc-v1"
chmod +x runsc containerd-shim-runsc-v1
sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/
sudo mkdir -p /etc/docker
printf '{"runtimes":{"runsc":{"path":"/usr/local/bin/runsc"}}}\n' |
sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
package-manager-cache: false
- name: Exercise bounded-query isolation under runsc
env:
AWF_BOUNDED_QUERY_TEST_RUNTIME: gvisor
run: |
npm ci
npm run build
npm run test:integration -- --runInBand bounded-query-isolation.test.ts

install-gvisor:
name: Install gVisor
runs-on: ubuntu-latest
Expand Down
74 changes: 51 additions & 23 deletions containers/agent/bounded-query-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
#
# Agent-facing bounded-query CLI (protocol v2).
#
# Forwards a *narrow* request to the trusted bounded-query broker over a
# dedicated Unix socket. It is analogous to gh-cli-proxy-wrapper.sh, but the
# Forwards a *narrow* request to the trusted bounded-query broker over either
# the Compose Unix socket or the authenticated sbx HTTP ingress. It is
# analogous to gh-cli-proxy-wrapper.sh, but the
# API is deliberately far narrower: this wrapper cannot express a command, an
# image, a path, a URL, a ref, a mount, a runtime, a timeout, an environment,
# or a credential. It accepts exactly:
Expand All @@ -29,7 +30,9 @@
# image).

CANONICAL_ERROR='{"status":"error"}'
SOCKET="${AWF_BOUNDED_QUERY_SOCKET:-/run/awf-bounded-query/broker.sock}"
SOCKET="${AWF_BOUNDED_QUERY_SOCKET:-}"
ENDPOINT="${AWF_BOUNDED_QUERY_ENDPOINT:-}"
CAPABILITY="${AWF_BOUNDED_QUERY_CAPABILITY:-}"
PROTOCOL_VERSION=2
# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-query/protocol.ts and
# containers/bounded-query/broker/protocol.js.
Expand Down Expand Up @@ -87,26 +90,51 @@ SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d
# The script must arrive on stdin; an interactive terminal means no script.
[ ! -t 0 ] || emit_error

[ -S "$SOCKET" ] || emit_error

# --noproxy '*' keeps HTTP(S)_PROXY from redirecting a Unix-socket request.
# --max-time bounds the wait comfortably above the largest timing bucket (10
# minutes); the broker always answers at a fixed bucket boundary, so this
# only guards a dead socket.
RESPONSE=$(
curl --silent --show-error \
--noproxy '*' \
--unix-socket "$SOCKET" \
--max-time 660 \
-X POST \
-H "Expect:" \
-H "Content-Type: application/octet-stream" \
-H "X-AWF-Query-Version: ${PROTOCOL_VERSION}" \
-H "X-AWF-Repo: ${REPO}" \
-H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
--data-binary @- \
"http://localhost/query" 2>/dev/null
) || emit_error
if [ -n "$SOCKET" ] && [ -z "$ENDPOINT" ] && [ -z "$CAPABILITY" ]; then
[ -S "$SOCKET" ] || emit_error
RESPONSE=$(
curl --silent --show-error \
--noproxy '*' \
--unix-socket "$SOCKET" \
--max-time 660 \
-X POST \
-H "Expect:" \
-H "Content-Type: application/octet-stream" \
-H "X-AWF-Query-Version: ${PROTOCOL_VERSION}" \
-H "X-AWF-Repo: ${REPO}" \
-H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
--data-binary @- \
"http://localhost/query" 2>/dev/null
) || emit_error
elif [ -z "$SOCKET" ] && [ -n "$ENDPOINT" ] && [ -n "$CAPABILITY" ]; then
case "$ENDPOINT" in
http://host.docker.internal:*/query)
PORT="${ENDPOINT#http://host.docker.internal:}"
PORT="${PORT%/query}"
printf '%s' "$PORT" | LC_ALL=C grep -Eq '^[0-9]{1,5}$' || emit_error
[ "$PORT" -ge 1 ] 2>/dev/null || emit_error
[ "$PORT" -le 65535 ] 2>/dev/null || emit_error
;;
*) emit_error ;;
esac
printf '%s' "$CAPABILITY" | LC_ALL=C grep -Eq '^[0-9a-f]{64}$' || emit_error
RESPONSE=$(
curl --silent --show-error \
--noproxy '*' \
--max-time 660 \
-X POST \
-H "Expect:" \
-H "Content-Type: application/octet-stream" \
-H "X-AWF-Capability: ${CAPABILITY}" \
-H "X-AWF-Query-Version: ${PROTOCOL_VERSION}" \
-H "X-AWF-Repo: ${REPO}" \
-H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
--data-binary @- \
"$ENDPOINT" 2>/dev/null
) || emit_error
else
emit_error
fi

# Pass the broker's canonical response through unmodified, but only if it has
# one of the two shapes the protocol ever produces. Anything else (a dead or
Expand Down
4 changes: 4 additions & 0 deletions containers/bounded-query/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ RUN chmod -R a-w /opt/awf \
&& node --check /opt/awf/broker/docker-client.js \
&& node --check /opt/awf/broker/docker-query-runner.js \
&& node --check /opt/awf/broker/gvisor-query-runner.js \
&& node --check /opt/awf/broker/sbx-client.js \
&& node --check /opt/awf/broker/sbx-capability-probe.js \
&& node --check /opt/awf/broker/sbx-query-runner-spec.js \
&& node --check /opt/awf/broker/sbx-query-runner.js \
&& node --check /opt/awf/broker/healthcheck.js

# Fixed broker-only mount points.
Expand Down
22 changes: 21 additions & 1 deletion containers/bounded-query/broker/broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,22 @@ function createBroker(params) {
const runner = params.runner;
const clock = params.clock || createRealClock();
const ledger = params.ledger || createLedger(seedMap);
const telemetry = params.telemetry || { emit() {} };

let invocationsUsed = 0;
let tail = Promise.resolve();
let accepting = true;

function emitQueryTelemetry(category) {
telemetry.emit({
primaryBackend: config.primaryBackend,
queryBackend: config.queryBackend,
lifecycleClass: 'query',
capabilityState: 'supported',
category,
});
}

/**
* Executes one request and reports its canonical result through
* `respond` (called exactly once). The invocations run only through
Expand All @@ -80,6 +91,7 @@ function createBroker(params) {
const validation = validateBoundedQueryRequest(request);
if (!validation.valid) {
audit.failure(invocationId, 'invalid-request', validation.errors.join('; '));
emitQueryTelemetry('invalid-request');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand All @@ -89,6 +101,7 @@ function createBroker(params) {
const seed = seedMap.get(repoKey);
if (!seed) {
audit.failure(invocationId, 'repo-not-allowed', privateRepo);
emitQueryTelemetry('repo-not-allowed');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand All @@ -100,6 +113,7 @@ function createBroker(params) {
const charge = queryBitsForSchema(schema);
if (!ledger.tryDebit(repoKey, charge)) {
audit.failure(invocationId, 'bit-budget-exhausted', `repo=${privateRepo} charge=${charge}`);
emitQueryTelemetry('bit-budget-exhausted');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand Down Expand Up @@ -175,6 +189,7 @@ function createBroker(params) {
// configured bucket — pathological infrastructure latency. Never emit a
// successful result at unbucketed timing.
audit.failure(invocationId, 'timing-bucket-overflow', failureReason ? failureReason.join(':') : undefined);
emitQueryTelemetry('timing-bucket-overflow');
safeRespond(CANONICAL_ERROR_JSON);
} else if (canonicalResult !== undefined) {
audit.invocation({
Expand All @@ -184,9 +199,12 @@ function createBroker(params) {
bits: charge,
bucketMs,
});
emitQueryTelemetry('success');
safeRespond(canonicalOkJson(canonicalResult));
} else {
audit.failure(invocationId, failureReason ? failureReason[0] : 'unknown', failureReason ? failureReason[1] : undefined);
const category = failureReason ? failureReason[0] : 'unknown';
audit.failure(invocationId, category, failureReason ? failureReason[1] : undefined);
emitQueryTelemetry(category);
safeRespond(CANONICAL_ERROR_JSON);
}

Expand Down Expand Up @@ -238,13 +256,15 @@ function createBroker(params) {
// against it.
if (invocationsUsed >= config.maxInvocations) {
audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`);
emitQueryTelemetry('invocation-count-exhausted');
safeRespond(CANONICAL_ERROR_JSON);
return Promise.resolve();
}
invocationsUsed += 1;

const queued = tail.then(() => execute(request, safeRespond)).catch((error) => {
audit.failure('queue', 'unexpected-error', error && error.message);
emitQueryTelemetry('unexpected-error');
safeRespond(CANONICAL_ERROR_JSON);
});
tail = queued.then(
Expand Down
48 changes: 45 additions & 3 deletions containers/bounded-query/broker/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const CONTROL_DIR = '/run/awf-bounded-query-control';
const AUDIT_DIR = '/var/log/awf-bounded-query';
/** Broker-private readiness marker; the control directory is never agent-mounted. */
const READY_PATH = path.join(CONTROL_DIR, 'broker.ready');
const SBX_CAPABILITY_PATH = path.join(CONTROL_DIR, 'sbx-ingress.json');
const QUERY_SECCOMP_PATH = '/opt/awf/query-seccomp.json';

/** Mount points inside the query container. Fixed, never caller-supplied. */
Expand All @@ -48,9 +49,27 @@ function parsePositiveInt(name, fallback) {
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`Environment variable ${name} must be a positive integer`);
}

return parsed;
}

function loadSbxIngressCapabilities(capabilityPath) {
const parsed = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
const pattern = /^[0-9a-f]{64}$/;
if (
!parsed
|| parsed.version !== 1
|| typeof parsed.query !== 'string'
|| typeof parsed.probe !== 'string'
|| !pattern.test(parsed.query)
|| !pattern.test(parsed.probe)
|| parsed.query === parsed.probe
) {
throw new Error('SBX ingress capability file is malformed');
}
return { query: parsed.query, probe: parsed.probe };
}

/**
* Parses the per-invocation timeout, additionally re-enforcing (defense in
* depth; AWF's host-side preflight already rejects an out-of-range value
Expand All @@ -75,9 +94,24 @@ function loadConfig() {
}

const queryBackend = requireEnv('AWF_BOUNDED_QUERY_BACKEND');
if (queryBackend !== 'docker' && queryBackend !== 'gvisor') {
if (queryBackend !== 'docker' && queryBackend !== 'gvisor' && queryBackend !== 'sbx') {
throw new Error(`Unsupported AWF_BOUNDED_QUERY_BACKEND: ${queryBackend}`);
}
const primaryBackend = requireEnv('AWF_BOUNDED_QUERY_PRIMARY_BACKEND');
if (primaryBackend !== 'docker' && primaryBackend !== 'gvisor' && primaryBackend !== 'sbx') {
throw new Error(`Unsupported AWF_BOUNDED_QUERY_PRIMARY_BACKEND: ${primaryBackend}`);
}

const tcpPortRaw = process.env.AWF_BOUNDED_QUERY_TCP_PORT;
const tcpPort = tcpPortRaw === undefined ? undefined : parsePositiveInt('AWF_BOUNDED_QUERY_TCP_PORT');
if (tcpPort !== undefined && tcpPort > 65535) {
throw new Error('AWF_BOUNDED_QUERY_TCP_PORT must be a valid TCP port');
}

const hostWorkDir = requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR');
const sbxWorkDir = queryBackend === 'sbx'
? requireEnv('AWF_BOUNDED_QUERY_SBX_WORK_DIR')
: undefined;

return {
seedsDir: SEEDS_DIR,
Expand All @@ -96,13 +130,21 @@ function loadConfig() {
queryImage: requireEnv('AWF_BOUNDED_QUERY_IMAGE'),
// The daemon resolves query bind-mount sources in *its* filesystem view,
// which is not necessarily the broker's (ARC/DinD split filesystems).
hostWorkDir: requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR'),
hostWorkDir,
// sbx and Docker daemons can have different filesystem namespaces (ARC/DinD).
// Never reuse the Docker-daemon-visible path for sbx mounts.
sbxWorkDir,
queryBackend,
primaryBackend,
timeoutSeconds: parseTimeoutSeconds(),
maxInvocations: parsePositiveInt('AWF_BOUNDED_QUERY_MAX_INVOCATIONS', 32),
memoryLimit,
socketUid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_UID', 0),
socketGid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_GID', 0),
tcpPort,
sbxIngressCapabilities: tcpPort === undefined
? undefined
: loadSbxIngressCapabilities(SBX_CAPABILITY_PATH),
};
}

Expand Down Expand Up @@ -145,4 +187,4 @@ function loadSeedMap(seedMapPath) {
return { runId: parsed.runId, seeds };
}

module.exports = { READY_PATH, loadConfig, loadSeedMap };
module.exports = { READY_PATH, SBX_CAPABILITY_PATH, loadConfig, loadSeedMap, loadSbxIngressCapabilities };
10 changes: 10 additions & 0 deletions containers/bounded-query/broker/framing.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

const { MAX_SCHEMA_BYTES, MAX_SCRIPT_BYTES, strictParseJson } = require('./protocol');

/** A peer that stops sending a request body cannot pin a broker connection. */
const BODY_READ_TIMEOUT_MS = 5_000;

/**
* Wire framing for the agent → broker request (protocol v2).
*
Expand Down Expand Up @@ -120,10 +123,16 @@ function readBoundedBody(req) {
const chunks = [];
let total = 0;
let settled = false;
const timer = setTimeout(() => {
req.pause();
finish({ error: 'request body deadline exceeded' });
}, BODY_READ_TIMEOUT_MS);
timer.unref();

const finish = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
};

Expand Down Expand Up @@ -160,4 +169,5 @@ module.exports = {
SCHEMA_HEADER,
buildRequestFromFrame,
readBoundedBody,
BODY_READ_TIMEOUT_MS,
};
Loading
Loading