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
2 changes: 2 additions & 0 deletions .github/workflows/fork-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ jobs:
- name: Security scenarios
run: |
bun run e2e:self-host -- --test-name 'application_type|fetch without a User-Agent names Executor|cannot create an all-apps connection|cannot widen a connection or mint|fails to load fails only its own reads'
- name: Operator feedback scenarios
run: bun run e2e:self-host --test-name 'self-host feedback webhook|self-host with DO_NOT_TRACK|self-host analytics pseudonymize|Hosted feedback enforces'
# Run the large cold catalog alone, not beside the functional scenarios' servers.
- name: Cold MCP discovery
run: bun run e2e:self-host --test-name 'Cold MCP discovery serves 8549'
Expand Down
7 changes: 5 additions & 2 deletions apps/docs/content/run/usage-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ EXECUTOR_DISABLE_ANALYTICS=1
```

Any value other than empty, `0`, `false`, `no` or `off` turns analytics off.
Nothing is recorded or sent, including feedback: the `feedback.submit` tool then
returns `FeedbackDisabled`.
Nothing is recorded or sent to analytics, including feedback: the `feedback.submit`
tool then returns `FeedbackDisabled` unless the self-host operator configured
`EXECUTOR_FEEDBACK_WEBHOOK_URL`. That independent receiver takes precedence for
feedback and receives the submitted message, sender identity and instance origin;
the anonymous analytics guarantees below do not apply to that explicit delivery.

## What is sent

Expand Down
31 changes: 31 additions & 0 deletions apps/hosted/self-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,37 @@ organization, and leaves it unset otherwise. Releases before the one that added
the record refuse every sign-in once a database holds a second organization, so
do not roll back below it after one exists.

### Operator feedback receiver

Set `EXECUTOR_FEEDBACK_WEBHOOK_URL` to send explicit `feedback.submit` submissions
from the API and Executor app to your own HTTP(S) receiver. Optionally set
`EXECUTOR_FEEDBACK_WEBHOOK_SECRET` for an `Authorization: Bearer` header. Keep both
in your host's secret store; credential-bearing webhook URLs are secrets too.
Use HTTPS except for a trusted local receiver. URLs with embedded username/password
credentials are refused; query-string tokens are supported.

The receiver gets a JSON POST containing `message`, `submittedAt` (ISO 8601),
`organization: { id }`, `user: { id, email? }`, `caller` (available authenticated
source/client/app attribution), and `instanceOrigin`. Unlike anonymous analytics,
this intentionally identifies the sender to the instance operator. Never include
credentials in feedback. `title` ("Executor feedback") and `body` (the first 1,500
message characters plus the sender) also support notification webhook services
without a separate adapter. An app's managed API key may identify its owner, not
the outer agent; unavailable agent attribution is not inferred from message text.

Only a 2xx response within 10 seconds returns `accepted`. Redirects, connection
failures, timeouts and rejection return `FeedbackUnavailable`, without disclosing
the receiver URL or response. Delivery is not retried automatically; after an
ambiguous timeout a caller retry may duplicate feedback. `Idempotency-Key` is a
unique per-delivery ID, not a deduplication key across separate caller submissions.

The webhook takes precedence over feedback analytics and works even with
`DO_NOT_TRACK=1`, `EXECUTOR_DISABLE_ANALYTICS=1`, or no analytics destination.
Other usage analytics are unchanged. With the URL unset or empty, existing
analytics feedback behavior is unchanged, including `FeedbackDisabled` when
analytics are unavailable. Submissions are not stored in the product database:
there is no feedback table, so durable retention belongs to the receiver for now.

### Trace sampling

Recording every span made Motel the busiest process under load, so the product
Expand Down
3 changes: 3 additions & 0 deletions apps/hosted/self-host/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ services:
SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:-}
SSO_ALLOWED_DOMAINS: ${SSO_ALLOWED_DOMAINS:-}
EXECUTOR_ENCRYPTION_KEY:
# Optional operator-owned feedback receiver, independent of anonymous analytics.
EXECUTOR_FEEDBACK_WEBHOOK_URL:
EXECUTOR_FEEDBACK_WEBHOOK_SECRET:
# Optional client-IP assertion, accepted only from the configured proxy peers.
EXECUTOR_TRUSTED_PROXY_HEADER:
EXECUTOR_TRUSTED_PROXIES:
Expand Down
75 changes: 75 additions & 0 deletions apps/hosted/self-host/src/implementation/feedback-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/** Explicit feedback delivery to an operator-owned receiver, independent of usage analytics. */
import { CurrentUsage, ProductAnalytics } from "@executor-js/hosted-server";
import { FeedbackUnavailable } from "@executor-js/telemetry/product-analytics";
import { Config, DateTime, Effect, Option, Redacted, Schema } from "effect";
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
import { SqlClient } from "effect/sql";

const Person = Schema.Struct({ email: Schema.String });

/** An unset URL leaves the existing analytics sink (including its opt-out) untouched. */
export const selfHostFeedbackWebhook = (origin: string) =>
Effect.gen(function* () {
const configured = yield* Config.Redacted("EXECUTOR_FEEDBACK_WEBHOOK_URL").pipe(Config.option);
if (Option.isNone(configured) || Redacted.value(configured.value).trim() === "")
return undefined;
const destination = URL.parse(Redacted.value(configured.value));
if (
destination === null ||
!["https:", "http:"].includes(destination.protocol) ||
destination.username !== "" ||
destination.password !== ""
) {
yield* Effect.logWarning(
"EXECUTOR_FEEDBACK_WEBHOOK_URL must be an HTTP(S) URL without embedded credentials",
);
return () => Effect.fail(new FeedbackUnavailable());
}
const secret = yield* Config.Redacted("EXECUTOR_FEEDBACK_WEBHOOK_SECRET").pipe(Config.option);
const sql = yield* SqlClient.SqlClient;

const submit: typeof ProductAnalytics.Service.submitFeedback = (feedback) =>
Effect.gen(function* () {
const caller = yield* CurrentUsage;
const rows = yield* sql`select email from "user" where id = ${feedback.userId}`;
const person = Schema.decodeUnknownOption(Person)(rows[0]);
const user = {
id: feedback.userId,
...(Option.isSome(person) ? { email: person.value.email } : {}),
};
const submittedAt = DateTime.formatIso(yield* DateTime.now);
const who = [user.email ?? user.id, caller.client_name].filter(Boolean).join(" / ");
const payload = {
message: feedback.message,
submittedAt,
organization: { id: feedback.organizationId },
user,
caller,
instanceOrigin: origin,
// Common notification receivers can consume these without a translation service.
title: "Executor feedback",
body: `${feedback.message.slice(0, 1500)}\nFrom: ${who.slice(0, 300)}`,
};
const request = HttpClientRequest.post(destination.href).pipe(
HttpClientRequest.bodyJsonUnsafe(payload),
HttpClientRequest.setHeader("Idempotency-Key", crypto.randomUUID()),
);
const response = yield* HttpClient.execute(
Option.isSome(secret) && Redacted.value(secret.value) !== ""
? HttpClientRequest.bearerToken(request, secret.value)
: request,
);
if (response.status < 200 || response.status >= 300)
return yield* new FeedbackUnavailable();
}).pipe(
Effect.timeout("10 seconds"),
Effect.tapError(() => Effect.logWarning("Feedback webhook delivery failed")),
Effect.mapError(() => new FeedbackUnavailable()),
// Do not trace the URL, authorization or response: all may contain receiver secrets.
Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
Effect.provideService(HttpClient.TracerPropagationEnabled, false),
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
Effect.provide(FetchHttpClient.layer),
);
return submit;
});
14 changes: 10 additions & 4 deletions apps/hosted/self-host/src/implementation/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { selfHostApi } from "./api.ts";
import { selfHostMcp } from "../mcp.ts";
import { SelfHostAuth } from "../auth.ts";
import { selfHostAnalytics } from "./product-analytics.ts";
import { selfHostFeedbackWebhook } from "./feedback-webhook.ts";

import type { SourceTextFile as SourceFile } from "@executor-js/sdk/core";
import type { selfHostExecutorServices } from "./executor-services.ts";
Expand All @@ -74,11 +75,16 @@ export const selfHostRouteMap = <DashboardE, DashboardR>(options: {
const { skills, egress, executorServices, dashboard } = options;
const auth = yield* SelfHostAuth.pipe(Effect.provide(executorServices));
const analytics = yield* selfHostAnalytics;
/** Requests and background schedules record through this instance's sink unless it opted out. */
const webhook = yield* selfHostFeedbackWebhook(auth.origin);
const feedbackSink = analytics?.product ?? (yield* ProductAnalytics);
/** The operator's feedback receiver remains available when usage analytics are off. */
const observed = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
analytics === undefined
? effect
: effect.pipe(Effect.provideService(ProductAnalytics, analytics.product));
effect.pipe(
Effect.provideService(
ProductAnalytics,
webhook === undefined ? feedbackSink : { ...feedbackSink, submitFeedback: webhook },
),
);
const scheduler = yield* Effect.gen(function* () {
const executor = yield* Effect.flatten(HostedExecutor);
const authorize = yield* ScheduledAuthority;
Expand Down
2 changes: 1 addition & 1 deletion apps/hosted/server/src/contracts/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const HostedFeedback = HttpApiGroup.make("feedback")
.annotate(OpenApi.Summary, "Submit feedback")
.annotate(
OpenApi.Description,
"Send feedback about Executor to the Executor team. Returns accepted only after ingestion succeeds. Returns FeedbackDisabled when this instance has analytics turned off. Do not include credentials or other sensitive information.",
"Send feedback about Executor to this instance's configured receiver (the Executor team by default). Returns accepted only after ingestion succeeds. Returns FeedbackDisabled when neither analytics nor an operator feedback receiver is available. Do not include credentials or other sensitive information.",
),
)
.middleware(RequireOrganization);
2 changes: 2 additions & 0 deletions e2e/support/managed-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class ServerFailed extends Schema.TaggedError<ServerFailed>()("ServerFailed", {
const maxClockOffset = 40 * 86_400_000;
/** Operator settings a scenario may turn on between product generations. */
export const OperatorSettings = Schema.Struct({
EXECUTOR_FEEDBACK_WEBHOOK_URL: Schema.optionalKey(Schema.String),
EXECUTOR_FEEDBACK_WEBHOOK_SECRET: Schema.optionalKey(Schema.String),
EXECUTOR_URL_ALLOW_LOOPBACK_HTTP: Schema.optionalKey(Schema.String),
EXECUTOR_URL_ALLOW_HTTP_ORIGINS: Schema.optionalKey(Schema.String),
NODE_EXTRA_CA_CERTS: Schema.optionalKey(Schema.NonEmptyString),
Expand Down
24 changes: 24 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3218,6 +3218,30 @@ export const scenarios = plan({
local: na("Local feedback has no organization; the local analytics scenario covers it."),
},
},
feedbackWebhook: {
fixtures: "actors",
file: "feedback-webhook.spec.ts",
title: "self-host feedback webhook awaits acceptance and preserves analytics fallback",
managementApps: ["owner"],
targets: {
"self-host": scheduled,
cloud: na("Operator feedback webhooks are self-host only."),
local: na("Operator feedback webhooks are self-host only."),
},
},
feedbackWebhookOptOut: {
fixtures: "actors",
file: "feedback-webhook.spec.ts",
title:
"self-host feedback webhook works with analytics disabled and surfaces receiver failures",
managementApps: ["owner"],
serverEnvironment: { DO_NOT_TRACK: "1" },
targets: {
"self-host": scheduled,
cloud: na("Operator feedback webhooks are self-host only."),
local: na("Operator feedback webhooks are self-host only."),
},
},
selfHostAnalytics: {
fixtures: "actors",
file: "instance-analytics.spec.ts",
Expand Down
111 changes: 111 additions & 0 deletions e2e/tests/docker-release.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,117 @@ const pinnedWorkerd = Effect.gen(function* () {
return `workerd ${manifest.devDependencies.workerd.replace(/^\d+\.(\d{4})(\d{2})(\d{2})\.\d+$/, "$1-$2-$3")}`;
});

/** Exercise the product Worker, not Bun: workerd supports manual redirects but not error mode. */
it.live("released image delivers operator feedback with analytics disabled", () =>
Effect.scoped(
Effect.gen(function* () {
const processes = yield* ChildProcessSpawner.ChildProcessSpawner;
const image = yield* Config.String("EXECUTOR_E2E_DOCKER_IMAGE");
const id = `executor-feedback-${randomBytes(8).toString("hex")}`;
const run = (args: readonly string[]) =>
processes.string(ChildProcess.make("docker", args, { stderr: "inherit" }));
yield* Effect.acquireRelease(run(["volume", "create", id]), () =>
run(["volume", "rm", id]).pipe(Effect.orDie),
);
yield* Effect.acquireRelease(
run([
"run",
"--detach",
"--network",
"none",
"--name",
id,
"--volume",
`${id}:/app/data`,
"--env",
"BETTER_AUTH_URL=http://localhost:4400",
"--env",
"DO_NOT_TRACK=1",
"--env",
"EXECUTOR_FEEDBACK_WEBHOOK_URL=http://127.0.0.1:4567/feedback",
"--env",
"EXECUTOR_FEEDBACK_WEBHOOK_SECRET=synthetic-native-feedback",
image,
]),
() => run(["rm", "--force", "--volumes", id]).pipe(Effect.orDie),
);
// The receiver and HTTP driver share only this container's network namespace.
// No host ports, extra Docker networks or external services are used.
const result = yield* run([
"run",
"--rm",
"--network",
`container:${id}`,
"node:24.22.1-bookworm-slim",
"node",
"--input-type=module",
"-e",
`
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { setTimeout } from "node:timers/promises";
const received = [];
let status = 204;
const receiver = createServer(async (request, response) => {
let text = "";
for await (const chunk of request) text += chunk;
received.push({ authorization: request.headers.authorization, payload: JSON.parse(text) });
response.writeHead(status, { location: "/feedback" });
response.end();
});
await new Promise(resolve => receiver.listen(4567, "127.0.0.1", resolve));
try {
const origin = "http://localhost:4400";
const request = (path, data, cookie) => fetch(origin + path, {
method: data === undefined ? "GET" : "POST",
headers: { origin, "content-type": "application/json", ...(cookie ? { cookie } : {}) },
...(data === undefined ? {} : { body: JSON.stringify(data) })
});
let healthy = false;
for (let attempt = 0; attempt < 200; attempt++) {
try { if ((await request("/health")).ok) { healthy = true; break; } } catch {}
await setTimeout(250);
}
assert.ok(healthy, "native product did not become healthy");
const setup = await request("/api/auth/self-host/setup", {
name: "Feedback Owner", email: "feedback@example.test",
password: "Synthetic-feedback-password-123!", organizationName: "Feedback lab"
});
assert.equal(setup.status, 200);
const cookie = setup.headers.getSetCookie().map(part => part.split(";")[0]).join("; ");
const organizations = await request("/api/auth/organization/list", undefined, cookie);
const [organization] = await organizations.json();
const path = "/api/organizations/" + organization.id + "/feedback";
const accepted = await request(path, { message: "Native feedback" }, cookie);
assert.equal(accepted.status, 200);
assert.deepEqual(await accepted.json(), { status: "accepted" });
assert.equal(received.length, 1);
assert.equal(received[0].authorization, "Bearer synthetic-native-feedback");
assert.equal(received[0].payload.user.email, "feedback@example.test");
assert.equal(received[0].payload.organization.id, organization.id);
assert.equal(received[0].payload.message, "Native feedback");
assert.equal(received[0].payload.instanceOrigin, origin);
for (const failure of [302, 503]) {
status = failure;
const rejected = await request(path, { message: "Native receiver failure" }, cookie);
assert.equal(rejected.status, 503);
assert.equal((await rejected.json())._tag, "FeedbackUnavailable");
}
assert.equal(received.length, 3, "redirect was followed or delivery retried");
console.log("Native feedback: accepted, attributed, redirects and failures refused");
} finally {
receiver.closeAllConnections();
await new Promise(resolve => receiver.close(resolve));
}
`,
]);
expect(result).toContain(
"Native feedback: accepted, attributed, redirects and failures refused",
);
}),
).pipe(Effect.provide(NodeServices.layer)),
);

for (const mode of ["explicit", "local", "railway"] as const)
it.live(`released image keeps login and encrypted credentials across restart (${mode})`, () =>
Effect.scoped(
Expand Down
Loading
Loading