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
46 changes: 46 additions & 0 deletions .github/scripts/otter-adapt-upstream.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,52 @@ preview_path.write_text(adapt_job(preview_path.read_text(), 'deploy', 'stage-pre
]))
PY

# Keep upstream's provisioning-profile path, but make it optional for Otter's
# existing Developer ID releases. Otter Accounts has no Clerk RP domain.
python3 - <<'PY'
from pathlib import Path

path = Path('.github/workflows/release-desktop.yml')
text = path.read_text()
if ' MACOS_PROVISIONING_PROFILE:\n' not in text:
text = text.replace(' AZURE_TENANT_ID:\n',
' MACOS_PROVISIONING_PROFILE:\n required: false\n AZURE_TENANT_ID:\n', 1)
if ' MACOS_PROVISIONING_PROFILE:' not in text:
text = text.replace(' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n',
' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n'
' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n', 1)
text = text.replace(' T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }}\n', '')
if ' T3CODE_MACOS_PASSKEY_RP_DOMAINS:' not in text:
text = text.replace(' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n',
' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n'
' T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }}\n', 1)
start_anchor = ' if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then\n'
end_anchor = ' echo "macOS signing enabled."\n'
start = text.index(start_anchor) + len(start_anchor)
end = text.index(end_anchor, start)
text = text[:start] + ''' key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
printf '%s' "$APPLE_API_KEY" > "$key_path"
export APPLE_API_KEY="$key_path"

if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then
if [[ -z "$APPLE_TEAM_ID" ]]; then
echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2
exit 1
fi
profile_path="$RUNNER_TEMP/t3code.provisionprofile"
printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path"
security cms -D -i "$profile_path" >/dev/null
export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID"
export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path"
echo "macOS provisioning profile validated; entitlements selected by the packager."
else
echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements."
fi

''' + text[end:]
path.write_text(text)
PY

# Point agents at the fork guide from AGENTS.md, which every agent reads first.
perl -0pi -e '
s/\A(# [^\n]*\n)/$1\n> **This is the Otter Code fork.** Read [OTTER.md](OTTER.md) first: it covers how the fork\n> stays in sync with upstream, its identities and infrastructure, releases, and moving machines.\n/
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ on:
required: false
APPLE_API_ISSUER:
required: false
MACOS_PROVISIONING_PROFILE:
required: false
AZURE_TENANT_ID:
required: false
AZURE_CLIENT_ID:
Expand Down Expand Up @@ -376,6 +378,8 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}
T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
Expand Down Expand Up @@ -408,6 +412,21 @@ jobs:
printf '%s' "$APPLE_API_KEY" > "$key_path"
export APPLE_API_KEY="$key_path"

if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then
if [[ -z "$APPLE_TEAM_ID" ]]; then
echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2
exit 1
fi
profile_path="$RUNNER_TEMP/t3code.provisionprofile"
printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path"
security cms -D -i "$profile_path" >/dev/null
export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID"
export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path"
echo "macOS provisioning profile validated; entitlements selected by the packager."
else
echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements."
fi

echo "macOS signing enabled."
args+=(--signed)
else
Expand Down
11 changes: 11 additions & 0 deletions OTTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ widget's `T3Mark.svg`, `agent_activity_mark.xml`, the DMG backgrounds) is the ki
packages with the `NPM_TOKEN` secret (an npm account that is a member of the `otterware` org),
tagged `nightly` and also `latest`, so `npx otter-code` and `npm i -g otter-code` get the newest
build. The token is needed because new packages have no npm trusted publisher yet.
- **macOS passkeys:** `MACOS_PROVISIONING_PROFILE` is the base64 Developer ID profile for
`dev.otterware.code`, signed by Clary GmbH's team `YNJ5WLH965` (`APPLE_TEAM_ID`). The
Otterware branch must pass its own `OTTERWARE_MACOS_PROVISIONING_PROFILE` secret into the
reusable workflow's `MACOS_PROVISIONING_PROFILE` input and use app ID `dev.otterware.suite`.
The packager derives the WebAuthn keychain group from the team and app IDs and claims only
used profile grants. Apple's managed `com.apple.developer.web-browser.public-key-credential`
capability is pending approval; after approval, regenerate both profiles and update their
secrets. Associated Domains are optional (`MACOS_PASSKEY_RP_DOMAINS`, comma-separated) and
unnecessary for browser passkeys; no Clerk key is required. Without a profile, existing
signing and notarization continue. Otter's policy lives in `scripts/otter/macos-profile.ts`;
regenerate workflow adaptations with `.github/scripts/otter-adapt-upstream.sh`.
- **Hosted web:** the release stages a Cloudflare Worker version, then promotes it after the
desktop/CLI release publishes. `scripts/otter/web/` owns its routing and static assets;
the adaptation script keeps upstream's Vercel path separate. The `Deploy Otter Code web`
Expand Down
17 changes: 10 additions & 7 deletions docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,23 +524,26 @@ Required secrets used by the workflow:
- `APPLE_API_KEY`
- `APPLE_API_KEY_ID`
- `APPLE_API_ISSUER`
- `MACOS_PROVISIONING_PROFILE` (base64-encoded provisioning profile with Associated Domains)

Required repository variables:

- `APPLE_TEAM_ID`

Optional repository variables:

domain from the production Otter Accounts issuer.
- `MACOS_PASSKEY_RP_DOMAINS`: explicitly configured, comma-separated Associated Domains.
Browser passkeys need none. The profile must authorize each configured domain.

Optional secret: `MACOS_PROVISIONING_PROFILE`, a base64-encoded Developer ID profile. Without
it, signing and notarization continue with the existing hardened-runtime entitlements.

Checklist:

1. Apple Developer account access:
- Team has rights to create Developer ID certificates.
2. Create an explicit App ID for `com.t3tools.t3code` and enable Associated Domains.
2. Create an explicit App ID for `dev.otterware.code`; enable Associated Domains only if used.
3. Create a `Developer ID Application` certificate and a compatible provisioning profile for that
App ID with Associated Domains enabled.
App ID with the passkey capabilities Apple has granted (see [OTTER.md](../../OTTER.md)).
4. Export the certificate + private key as `.p12` from Keychain.
5. Base64-encode the `.p12` and store as `CSC_LINK`.
6. Base64-encode the provisioning profile and store it as `MACOS_PROVISIONING_PROFILE`.
Expand All @@ -551,8 +554,8 @@ Checklist:
- `APPLE_API_KEY`: contents of the downloaded `.p8`
- `APPLE_API_KEY_ID`: Key ID
- `APPLE_API_ISSUER`: Issuer ID
10. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected
`com.apple.developer.associated-domains` entitlement.
10. Re-run a release and confirm macOS artifacts are signed/notarized and contain only the
configured, profile-authorized passkey entitlements reported by the packager.

Notes:

Expand All @@ -563,7 +566,7 @@ Notes:
- In-app browser passkeys depend on the same profile. The packager adds each entitlement only when
the profile grants it, because macOS will not launch an app that claims more than its profile
allows. The build log reports which ones it enabled.
- `keychain-access-groups` (`<TEAM_ID>.com.t3tools.t3code.webauthn`) enables Touch ID passkeys.
- `keychain-access-groups` (`<TEAM_ID>.<APP_ID>.webauthn`) enables Touch ID passkeys.
Profiles that grant the team's keychain groups (`<TEAM_ID>.*`) cover it.
- `com.apple.developer.web-browser.public-key-credential` lets the system passkey sheet (iCloud
Keychain, password managers, phones, security keys) serve any site. Apple grants it as a
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

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

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ catalog:
"@tailwindcss/oxide": 4.3.3
"@tailwindcss/vite": 4.3.3
"@types/node": 24.12.4
"@types/plist": 3.0.5
"@types/ws": 8.18.2
effect: 4.0.2
electron-webauthn: 1.3.1
jose: 6.2.2
lightningcss: 1.33.0
objc-js: 1.5.0
plist: 3.1.1
tailwindcss: 4.3.3
tldts: 7.4.2
typescript: 7.0.2
Expand Down
20 changes: 20 additions & 0 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1950,6 +1950,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
),
);

it.effect("keeps default signing when no macOS provisioning profile is configured", () =>
Effect.gen(function* () {
const config = yield* createBuildConfig(
"mac",
"dmg",
"1.2.3",
true,
false,
undefined,
undefined,
);
const mac = config.mac as Record<string, unknown>;
assert.notProperty(mac, "entitlements");
assert.notProperty(mac, "provisioningProfile");
// electron-builder enables hardened runtime by default.
assert.notProperty(mac, "hardenedRuntime");
assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/);
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
);

it.effect("adds signing entitlements and both renderer protocols to signed macOS builds", () =>
Effect.gen(function* () {
const config = yield* createBuildConfig("mac", "dmg", "1.2.3", true, false, undefined, {
Expand Down
83 changes: 81 additions & 2 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import * as NodeFSP from "node:fs/promises";
import * as NodeCrypto from "node:crypto";
import * as NodeModule from "node:module";
// plist is CommonJS; Node cannot load its named exports from an ES module.

import {
createPackageWithOptions,
Expand Down Expand Up @@ -34,6 +33,13 @@ import {
selectCliRuntimeExternalDependencies,
} from "./lib/cli-external-packages.ts";
import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts";
import { loadRepoEnv } from "./lib/public-config.ts";
import {
resolveMacPasskeySigningConfiguration,
resolveMacWebAuthnEntitlements,
renderMacPasskeyEntitlements,
type MacWebAuthnEntitlements,
} from "./otter/macos-profile.ts";
import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";

import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
Expand Down Expand Up @@ -196,6 +202,28 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B
return yield* getDefaultBuildArch(platform, config);
});

export class MacPasskeySigningConfigurationResolutionError extends Schema.TaggedError<MacPasskeySigningConfigurationResolutionError>()(
"MacPasskeySigningConfigurationResolutionError",
{
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to resolve macOS passkey signing configuration.";
}
}

export class MacProvisioningProfileNotFoundError extends Schema.TaggedError<MacProvisioningProfileNotFoundError>()(
"MacProvisioningProfileNotFoundError",
{
provisioningProfilePath: Schema.String,
},
) {
override get message(): string {
return `macOS provisioning profile not found: ${this.provisioningProfilePath}`;
}
}

export class KeyringNativePackageMissingError extends Schema.TaggedError<KeyringNativePackageMissingError>()(
"KeyringNativePackageMissingError",
{
Expand Down Expand Up @@ -907,6 +935,7 @@ interface StagePackageJson {
readonly version: string;
readonly buildVersion: string;
readonly t3codeCommitHash: string;
readonly t3codeWebAuthn?: MacWebAuthnEntitlements;
readonly private: true;
readonly packageManager: string;
readonly description: string;
Expand Down Expand Up @@ -3440,6 +3469,49 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources");
yield* fs.copy(stageResourcesDir, stageProdResourcesDir);

const configuredMacPasskeySigning =
options.platform === "mac" && options.signed
? yield* Effect.try({
try: () =>
resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot }), DESKTOP_APP_ID),
catch: (cause) => new MacPasskeySigningConfigurationResolutionError({ cause }),
})
: undefined;
const macPasskeySigning = configuredMacPasskeySigning
? {
...configuredMacPasskeySigning,
provisioningProfilePath: path.resolve(
repoRoot,
configuredMacPasskeySigning.provisioningProfilePath,
),
}
: undefined;
const macEntitlementsPath = macPasskeySigning
? path.join(stageAppDir, "entitlements.mac.plist")
: undefined;
let macWebAuthn: MacWebAuthnEntitlements | undefined;
if (macPasskeySigning && macEntitlementsPath) {
if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) {
return yield* new MacProvisioningProfileNotFoundError({
provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
});
}
const profile = yield* fs.readFileString(macPasskeySigning.provisioningProfilePath);
macWebAuthn = resolveMacWebAuthnEntitlements(profile, macPasskeySigning);
yield* Effect.log(
`[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`,
);
const entitlements = renderMacPasskeyEntitlements(macPasskeySigning, macWebAuthn, profile);
yield* fs.writeFileString(macEntitlementsPath, entitlements);
yield* Effect.log(
`[desktop-artifact] macOS provisioning profile embedded; enabled entitlements: ${[...entitlements.matchAll(/<key>([^<]+)<\/key>/gu)].map((match) => match[1]).join(", ")}.`,
);
} else if (options.platform === "mac") {
yield* Effect.log(
"[desktop-artifact] No macOS provisioning profile embedded; passkey entitlements disabled; default hardened-runtime entitlements retained.",
);
}

// Windows splits dependencies per process: app.asar carries only the
// desktop main-process externals, while the server bundle's externals live
// in the server.asar sidecar (see stageWindowsServerSidecar). macOS and
Expand Down Expand Up @@ -3467,6 +3539,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
version: appVersion,
buildVersion: appVersion,
t3codeCommitHash: commitHash,
// Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements.
...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}),
private: true,
packageManager: rootPackageJson.packageManager,
description:
Expand All @@ -3483,7 +3557,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
options.signed,
options.mockUpdates,
options.mockUpdateServerPort,
undefined,
macPasskeySigning && macEntitlementsPath
? {
entitlementsPath: macEntitlementsPath,
provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
}
: undefined,
bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }),
options.arch,
),
Expand Down
Loading
Loading