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: 8 additions & 0 deletions .changeset/credential-host-underscores.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"apps": minor
"@executor-js/sdk": minor
---

Provider `hosts` may name hosts with underscores, such as the Docker container
`n8n_server_1:5678`. This is host protocol 14. Apps on earlier `apps` releases
keep running; an account they use never shows them a host with an underscore.
4 changes: 4 additions & 0 deletions apps/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# executor

## 2.0.0-beta.35

Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image.

## 2.0.0-beta.34

Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "executor",
"version": "2.0.0-beta.34",
"version": "2.0.0-beta.35",
"private": true,
"description": "Run Executor locally and connect your agents to your apps.",
"homepage": "https://executor.sh",
Expand Down
8 changes: 4 additions & 4 deletions apps/hosted/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
},
"dependencies": {
"@alchemy.run/better-auth": "2.0.0-beta.80",
"@better-auth/core": "1.7.5",
"@better-auth/passkey": "1.7.5",
"@better-auth/sso": "1.7.5",
"@better-auth/core": "1.7.16",
"@better-auth/passkey": "1.7.16",
"@better-auth/sso": "1.7.16",
"@cloudflare/worker-bundler": "0.2.4",
"@distilled.cloud/cloudflare": "1.0.0-rc.13",
"@distilled.cloud/posthog": "1.0.0-rc.13",
Expand All @@ -43,7 +43,7 @@
"alchemy": "2.0.0-beta.80",
"apps": "workspace:*",
"autumn-js": "1.2.28",
"better-auth": "1.7.5",
"better-auth": "1.7.16",
"effect": "4.0.1",
"fumadb-effect": "workspace:*",
"js-yaml": "5.4.3",
Expand Down
2 changes: 2 additions & 0 deletions apps/hosted/cloud/src/contracts/v1-import-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ const Artifact = Schema.Struct({
id: Schema.String,
title: Schema.String,
unmappedRoles: Schema.Array(Schema.Struct({ role: Schema.String, problem: Schema.String })),
/** Why it fails, or would: absent when it moves. */
error: Schema.optionalKey(Schema.String),
});
const VaultFailure = Schema.Struct({
id: Schema.String,
Expand Down
14 changes: 14 additions & 0 deletions apps/hosted/cloud/src/implementation/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ export const migrateV1ImportMap = Effect.gen(function* () {
yield* sql`create index if not exists private_v1_import_map_org on private_v1_import_map (v1_org_id)`;
}).pipe(Effect.mapError(migrationFailed("product")));

/**
* A failed retry may keep the v2 id an earlier attempt saved. Imported rows still require one.
* The previous check required every non-imported row to have no id, so a failure erased it and
* the next attempt could revive a revoked toolkit copy. Relaxing the check leaves every row the
* running server writes valid: it still records a failure with no id.
*/
export const migrateV1ImportFailedKeepsId = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`alter table private_v1_import_map drop constraint if exists private_v1_import_map_check`;
yield* sql`alter table private_v1_import_map add constraint private_v1_import_map_imported_has_id
check (status <> 'imported' or v2_id is not null)`;
}).pipe(Effect.mapError(migrationFailed("product")));

/**
* Each v1 organization's import state (`notes/v1-import.md`): the durable record v1's freeze,
* redirect and forwarding will read, which outlives a Workflow instance's retention. Only an import
Expand Down Expand Up @@ -302,6 +315,7 @@ export const migrateCloudDatabase = Effect.scoped(
"11_v1_import_billing": migrateV1ImportBilling,
"12_v1_import_displacement": migrateV1ImportDisplacement,
"13_v1_import_into": migrateV1ImportInto,
"14_v1_import_failed_keeps_id": migrateV1ImportFailedKeepsId,
});
});
yield* migrateHostedDatabase(setup.options, cloudMigrations).pipe(
Expand Down
34 changes: 25 additions & 9 deletions apps/hosted/cloud/src/implementation/oauth-proxy-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
* `oAuthProxy` so its before hooks run first.
*
* Recognition of a proxy package matches the plugin's own truthiness test. A
* stricter test would let a package the plugin still accepts skip this guard, and
* anything that decrypts under the proxy secret but cannot be understood is
* refused rather than waved through.
* stricter test would let a package the plugin still accepts skip this guard.
* better-auth 1.7.16 encrypts that package and its state cookie under
* purpose-derived keys, not the raw secret. Decrypting with the raw secret fails,
* and treating that failure as "not a proxy package" lets the plugin redirect to
* whatever target the ciphertext names. A decrypt or parse failure of anything
* other than a normal sign-in state is refused here: a redirect thrown later, in
* the plugin's before hook, never reaches the after hooks.
*
* The plugin also rewrites the outgoing `Location` on `/callback/:id` from an
* unvalidated `callbackURL` parameter. `oauthProxyLocationGuard` re-checks that
Expand All @@ -21,7 +25,7 @@
*/
import type { BetterAuthPlugin } from "better-auth";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { symmetricDecrypt } from "better-auth/crypto";
import { derivePurposeKey, symmetricDecrypt } from "better-auth/crypto";

/** Endpoints that only a test stage needs; production must not accept a profile. */
const completionPaths = new Set(["/callback/:id/oauth-proxy", "/oauth-proxy-callback"]);
Expand Down Expand Up @@ -83,24 +87,36 @@ export const proxyRedirectOrigins = (stateData: unknown): ReadonlyArray<string>
return [...origins];
};

/**
* Production's own sign-in state: `generateRandomString(32)`. It is not ciphertext.
* A proxy package is hex from `symmetricEncrypt` and is far longer.
*/
const regularSignInState = /^[A-Za-z0-9_-]{32}$/u;

const decryptJson = async (key: string, data: string): Promise<unknown> =>
JSON.parse(await symmetricDecrypt({ key, data }));

const proxiedRedirectOrigins = async (
secret: string,
state: unknown,
): Promise<ReadonlyArray<string> | undefined> => {
if (typeof state !== "string" || state.length === 0) return undefined;
let statePackage: unknown;
try {
statePackage = JSON.parse(await symmetricDecrypt({ key: secret, data: state }));
statePackage = await decryptJson(derivePurposeKey(secret, "oauth-proxy-package"), state);
} catch {
// Not a proxy package. Production's own sign-ins use the regular callback.
return undefined;
// The plugin uses this same key. A normal sign-in token is not a package, so the
// callback continues. Anything else that will not decrypt is refused: the plugin
// must not be left to follow a target this guard could not read.
if (regularSignInState.test(state)) return undefined;
throw badState("OAuth proxy state cannot be read.");
}
if (!isProxyStatePackage(statePackage)) return undefined;
if (!isProxyStatePackage(statePackage)) throw badState("OAuth proxy state cannot be read.");
const stateCookie = (statePackage as { stateCookie: unknown }).stateCookie;
if (typeof stateCookie !== "string") throw badState("OAuth proxy state cannot be read.");
let stateData: unknown;
try {
stateData = JSON.parse(await symmetricDecrypt({ key: secret, data: stateCookie }));
stateData = await decryptJson(derivePurposeKey(secret, "oauth-proxy-state"), stateCookie);
} catch {
throw badState("OAuth proxy state cannot be read.");
}
Expand Down
132 changes: 95 additions & 37 deletions apps/hosted/cloud/src/implementation/v1-import/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,37 +664,50 @@ const restriction = { approve: 1, require_approval: 2, block: 3 } as const;
* but only when stricter: a member's `approve` result, including "don't ask again", is dropped, so
* the tool asks again (decision 10). Each member's own first match still decides, so a member's
* `block` that their own earlier `approve` shadowed in v1 stays shadowed.
*
* The policies are ordered and their actions read once, so deciding each tool only matches them.
*/
export const resolvePolicy = (
toolId: string,
policies: readonly PolicyRow[],
multiMember = false,
): PolicyAction | undefined => {
const sorted = [...policies].sort((a, b) =>
a.position < b.position
? -1
: a.position > b.position
? 1
: a.id < b.id
? -1
: a.id > b.id
? 1
: 0,
);
const first = new Map<string, { readonly owner: V1Owner; readonly action: PolicyAction }>();
for (const row of sorted) {
const partition = row.owner === "org" ? "org" : `user:${row.subject}`;
if (first.has(partition)) continue;
const action = Schema.decodeUnknownOption(PolicyAction)(row.action);
if (Option.isSome(action) && matchPattern(row.pattern, toolId))
first.set(partition, { owner: row.owner, action: action.value });
}
let selected: PolicyAction | undefined;
for (const { owner, action } of first.values()) {
if (multiMember && owner === "user" && action === "approve") continue;
if (selected === undefined || restriction[action] > restriction[selected]) selected = action;
}
return selected;
export const policyResolver = (policies: readonly PolicyRow[], multiMember = false) => {
const ordered = [...policies]
.sort((a, b) =>
a.position < b.position
? -1
: a.position > b.position
? 1
: a.id < b.id
? -1
: a.id > b.id
? 1
: 0,
)
.flatMap((row) => {
// A row whose action v1 does not know never matched a tool.
const action = Schema.decodeUnknownOption(PolicyAction)(row.action);
return Option.isSome(action)
? [
{
partition: row.owner === "org" ? "org" : `user:${row.subject}`,
owner: row.owner,
pattern: row.pattern,
action: action.value,
},
]
: [];
});
return (toolId: string): PolicyAction | undefined => {
const first = new Map<string, { readonly owner: V1Owner; readonly action: PolicyAction }>();
for (const row of ordered) {
if (first.has(row.partition)) continue;
if (matchPattern(row.pattern, toolId))
first.set(row.partition, { owner: row.owner, action: row.action });
}
let selected: PolicyAction | undefined;
for (const { owner, action } of first.values()) {
if (multiMember && owner === "user" && action === "approve") continue;
if (selected === undefined || restriction[action] > restriction[selected]) selected = action;
}
return selected;
};
};

/** v1 accepted only these patterns; anything else never matched a tool. */
Expand Down Expand Up @@ -767,23 +780,68 @@ const byPosition = (a: ToolkitRuleData, b: ToolkitRuleData) =>
? 1
: 0;

/** A toolkit rule that decodes, with its owner partition. */
interface DecodedToolkitRule {
readonly owner: ToolkitRuleRow["owner"];
readonly subject: string;
readonly collection: ToolkitRuleRow["collection"];
readonly rule: ToolkitRuleData;
}

/** The rules that decode, in their order; v1 skipped the others. */
const decodeToolkitRules = (rules: readonly ToolkitRuleRow[]): readonly DecodedToolkitRule[] =>
rules.flatMap((rule) => {
const decoded = Schema.decodeUnknownOption(ToolkitRuleData)(jsonValue(rule.data));
return Option.isSome(decoded)
? [
{
owner: rule.owner,
subject: rule.subject,
collection: rule.collection,
rule: decoded.value,
},
]
: [];
});

/**
* A toolkit and its rules, or undefined when its row does not decode. v1 reads an `approve` policy
* ending in `*` with three or four segments as access, unless a connection rule already covers it.
*/
export const decodeToolkit = (
row: ToolkitRow,
rules: readonly ToolkitRuleRow[],
): V1Toolkit | undefined =>
toolkitWith(
row,
decodeToolkitRules(
rules.filter((rule) => rule.owner === row.owner && rule.subject === row.subject),
),
);

/** Every toolkit that decodes, as `decodeToolkit` reads it, decoding each rule once. */
export const decodeToolkits = (
rows: readonly ToolkitRow[],
rules: readonly ToolkitRuleRow[],
): readonly V1Toolkit[] => {
const decoded = decodeToolkitRules(rules);
return rows.flatMap((row) => {
const toolkit = toolkitWith(row, decoded);
return toolkit === undefined ? [] : [toolkit];
});
};

/** A toolkit with the decoded rules of its owner partition and id. */
const toolkitWith = (
row: ToolkitRow,
rules: readonly DecodedToolkitRule[],
): V1Toolkit | undefined => {
const data = Option.getOrUndefined(Schema.decodeUnknownOption(ToolkitData)(jsonValue(row.data)));
if (data === undefined) return undefined;
const own = rules.flatMap((rule) => {
if (rule.owner !== row.owner || rule.subject !== row.subject) return [];
const decoded = Schema.decodeUnknownOption(ToolkitRuleData)(jsonValue(rule.data));
return Option.isSome(decoded) && decoded.value.toolkitId === data.id
? [{ collection: rule.collection, rule: decoded.value }]
: [];
});
const own = rules.filter(
(rule) =>
rule.owner === row.owner && rule.subject === row.subject && rule.rule.toolkitId === data.id,
);
const connections = own
.filter((entry) => entry.collection === "toolkitConnections")
.map((entry) => entry.rule);
Expand Down
Loading
Loading