| author | |
| committer | |
| log | d514a8c6176ace9057dc8c7619f0b5f0d81d1bd0 |
| tree | 310bfe913e79363872fa8bd23bdccfc842c81e02 |
| parent | 37e2acda5118d13c144efb009d4911ff8f65d6ed |
| signature | Signed by PGP key B5690EEEBB952194 |
15 files changed, 658 insertions(+), 460 deletions(-)
apps/server/src/serverSettings.test.ts+175-9| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | resolveProviderInstanceEnabled, |
| 10 | 10 | ServerSettings, |
| 11 | 11 | ServerSettingsPatch, |
| 12 | UsageLimitSourceId, | |
| 12 | 13 | } from "@t3tools/contracts"; |
| 13 | 14 | import { createModelSelection } from "@t3tools/shared/model"; |
| 14 | 15 | import { assert, it } from "@effect/vitest"; |
| ... | ... | @@ -1440,6 +1441,156 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1440 | 1441 | }).pipe(Effect.provide(layerServerSettings())), |
| 1441 | 1442 | ); |
| 1442 | 1443 | |
| 1444 | it.effect( | |
| 1445 | "uses the same secret lifecycle for provider config, environment, host tokens, and quota keys", | |
| 1446 | () => | |
| 1447 | Effect.gen(function* () { | |
| 1448 | const service = yield* ServerSettingsModule.ServerSettingsService; | |
| 1449 | const config = yield* ServerConfig.ServerConfig; | |
| 1450 | const fs = yield* FileSystem.FileSystem; | |
| 1451 | const store = yield* ServerSecretStore.ServerSecretStore; | |
| 1452 | const instanceId = ProviderInstanceId.make("antigravity_secrets"); | |
| 1453 | const saved = yield* service.updateSettings({ | |
| 1454 | providerInstances: { | |
| 1455 | [instanceId]: { | |
| 1456 | driver: ProviderDriverKind.make("antigravity"), | |
| 1457 | config: { apiKey: "config-secret" }, | |
| 1458 | environment: [ | |
| 1459 | { name: "API_TOKEN", value: "env-secret", sensitive: true }, | |
| 1460 | { name: "BASE_URL", value: "https://example.test", sensitive: false }, | |
| 1461 | ], | |
| 1462 | }, | |
| 1463 | }, | |
| 1464 | sourceControlHosts: { | |
| 1465 | github: { tokens: { "github.com": "github-secret" } }, | |
| 1466 | bitbucket: { email: "me@example.test", accessToken: "bitbucket-secret" }, | |
| 1467 | gitcafe: { token: "gitcafe-secret" }, | |
| 1468 | }, | |
| 1469 | usageLimitSources: { | |
| 1470 | [UsageLimitSourceId.make("hub")]: { | |
| 1471 | kind: "cliproxy", | |
| 1472 | url: "https://hub.example.test", | |
| 1473 | managementKey: "quota-secret", | |
| 1474 | enabled: true, | |
| 1475 | }, | |
| 1476 | }, | |
| 1477 | }); | |
| 1478 | const values = [ | |
| 1479 | "config-secret", | |
| 1480 | "env-secret", | |
| 1481 | "github-secret", | |
| 1482 | "bitbucket-secret", | |
| 1483 | "gitcafe-secret", | |
| 1484 | "quota-secret", | |
| 1485 | ]; | |
| 1486 | const client = ServerSettingsModule.redactServerSettingsForClient(saved); | |
| 1487 | const disk = yield* fs.readFileString(config.settingsPath); | |
| 1488 | for (const value of values) { | |
| 1489 | assert.include(JSON.stringify(saved), value); | |
| 1490 | assert.notInclude(JSON.stringify(client), value); | |
| 1491 | assert.notInclude(disk, value); | |
| 1492 | } | |
| 1493 | assert.include(JSON.stringify(client), "https://example.test"); | |
| 1494 | assert.include(JSON.stringify(client), "me@example.test"); | |
| 1495 | const echoed = yield* service.updateSettings({ | |
| 1496 | providerInstances: client.providerInstances, | |
| 1497 | sourceControlHosts: client.sourceControlHosts, | |
| 1498 | usageLimitSources: client.usageLimitSources, | |
| 1499 | }); | |
| 1500 | for (const value of values) assert.include(JSON.stringify(echoed), value); | |
| 1501 | const renamed = yield* service.updateProviderInstance({ | |
| 1502 | operation: "upsert", | |
| 1503 | instanceId, | |
| 1504 | instance: { ...client.providerInstances[instanceId]!, displayName: "Renamed" }, | |
| 1505 | }); | |
| 1506 | assert.propertyVal( | |
| 1507 | renamed.providerInstances[instanceId]?.config, | |
| 1508 | "apiKey", | |
| 1509 | "config-secret", | |
| 1510 | ); | |
| 1511 | ||
| 1512 | for (const apiKey of ["replacement-secret", ""]) { | |
| 1513 | const changed = yield* service.updateProviderInstance({ | |
| 1514 | operation: "upsert", | |
| 1515 | instanceId, | |
| 1516 | instance: { ...client.providerInstances[instanceId]!, config: { apiKey } }, | |
| 1517 | }); | |
| 1518 | assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey); | |
| 1519 | const redacted = ServerSettingsModule.redactServerSettingsForClient(changed); | |
| 1520 | assert.propertyVal( | |
| 1521 | redacted.providerInstances[instanceId]?.config, | |
| 1522 | "apiKey", | |
| 1523 | apiKey ? "••••••" : "", | |
| 1524 | ); | |
| 1525 | } | |
| 1526 | // Restore the value so removing the instance below exercises deletion of both secrets. | |
| 1527 | yield* service.updateProviderInstance({ | |
| 1528 | operation: "upsert", | |
| 1529 | instanceId, | |
| 1530 | instance: saved.providerInstances[instanceId]!, | |
| 1531 | }); | |
| 1532 | ||
| 1533 | const secretNames = [ | |
| 1534 | "provider-config-YW50aWdyYXZpdHlfc2VjcmV0cw-YW50aWdyYXZpdHk-YXBpS2V5", | |
| 1535 | "provider-env-YW50aWdyYXZpdHlfc2VjcmV0cw-QVBJX1RPS0VO", | |
| 1536 | sourceControlHostSecretName({ | |
| 1537 | kind: "github", | |
| 1538 | field: "tokens", | |
| 1539 | serverHost: "github.com", | |
| 1540 | }), | |
| 1541 | sourceControlHostSecretName({ | |
| 1542 | kind: "bitbucket", | |
| 1543 | field: "accessToken", | |
| 1544 | serverHost: null, | |
| 1545 | }), | |
| 1546 | sourceControlHostSecretName({ kind: "gitcafe", field: "token", serverHost: null }), | |
| 1547 | "usage-limit-source-aHVi", | |
| 1548 | ]; | |
| 1549 | for (const name of secretNames) { | |
| 1550 | assert.isTrue(Option.isSome(yield* store.get(name)), name); | |
| 1551 | } | |
| 1552 | yield* service.updateSettings({ | |
| 1553 | providerInstances: {}, | |
| 1554 | sourceControlHosts: { | |
| 1555 | github: { tokens: {} }, | |
| 1556 | bitbucket: { accessToken: "" }, | |
| 1557 | gitcafe: { token: "" }, | |
| 1558 | }, | |
| 1559 | usageLimitSources: { [UsageLimitSourceId.make("hub")]: null }, | |
| 1560 | }); | |
| 1561 | for (const name of secretNames) { | |
| 1562 | assert.isTrue(Option.isNone(yield* store.get(name))); | |
| 1563 | } | |
| 1564 | }).pipe(Effect.provide(layerServerSettingsWithSecrets())), | |
| 1565 | ); | |
| 1566 | ||
| 1567 | it.effect("moves a saved Antigravity key to the secret store when settings load", () => | |
| 1568 | Effect.gen(function* () { | |
| 1569 | const service = yield* ServerSettingsModule.ServerSettingsService; | |
| 1570 | const config = yield* ServerConfig.ServerConfig; | |
| 1571 | const fs = yield* FileSystem.FileSystem; | |
| 1572 | yield* fs.writeFileString( | |
| 1573 | config.settingsPath, | |
| 1574 | JSON.stringify({ | |
| 1575 | providerInstances: { | |
| 1576 | antigravity: { driver: "antigravity", config: { apiKey: "legacy-api-key" } }, | |
| 1577 | }, | |
| 1578 | }), | |
| 1579 | ); | |
| 1580 | const saved = yield* service.getSettings; | |
| 1581 | assert.propertyVal( | |
| 1582 | saved.providerInstances[ProviderInstanceId.make("antigravity")]?.config, | |
| 1583 | "apiKey", | |
| 1584 | "legacy-api-key", | |
| 1585 | ); | |
| 1586 | assert.notInclude(yield* fs.readFileString(config.settingsPath), "legacy-api-key"); | |
| 1587 | assert.notInclude( | |
| 1588 | JSON.stringify(ServerSettingsModule.redactServerSettingsForClient(saved)), | |
| 1589 | "legacy-api-key", | |
| 1590 | ); | |
| 1591 | }).pipe(Effect.provide(layerServerSettings())), | |
| 1592 | ); | |
| 1593 | ||
| 1443 | 1594 | it.effect("stores sensitive provider instance environment values outside settings.json", () => |
| 1444 | 1595 | Effect.gen(function* () { |
| 1445 | 1596 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| ... | ... | @@ -1714,7 +1865,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1714 | 1865 | ? Effect.fail(writeFailure) |
| 1715 | 1866 | : fileSystem.rename(fromPath, toPath), |
| 1716 | 1867 | }); |
| 1717 | const instanceId = ProviderInstanceId.make("codex_write_failure"); | |
| 1868 | const instanceId = ProviderInstanceId.make("antigravity_write_failure"); | |
| 1718 | 1869 | const layerSettings = layerServerSettings().pipe( |
| 1719 | 1870 | Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, failingFileSystem)), |
| 1720 | 1871 | ); |
| ... | ... | @@ -1726,9 +1877,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1726 | 1877 | operation: "upsert", |
| 1727 | 1878 | instanceId, |
| 1728 | 1879 | instance: { |
| 1729 | driver: ProviderDriverKind.make("codex"), | |
| 1880 | driver: ProviderDriverKind.make("antigravity"), | |
| 1730 | 1881 | environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }], |
| 1731 | config: {}, | |
| 1882 | config: { apiKey: "config-kept" }, | |
| 1732 | 1883 | }, |
| 1733 | 1884 | }); |
| 1734 | 1885 | |
| ... | ... | @@ -1738,9 +1889,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1738 | 1889 | operation: "upsert", |
| 1739 | 1890 | instanceId, |
| 1740 | 1891 | instance: { |
| 1741 | driver: ProviderDriverKind.make("codex"), | |
| 1892 | driver: ProviderDriverKind.make("antigravity"), | |
| 1742 | 1893 | environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }], |
| 1743 | config: {}, | |
| 1894 | config: { apiKey: "config-new" }, | |
| 1744 | 1895 | }, |
| 1745 | 1896 | }) |
| 1746 | 1897 | .pipe(Effect.result); |
| ... | ... | @@ -1750,6 +1901,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1750 | 1901 | ?.value, |
| 1751 | 1902 | "sk-kept", |
| 1752 | 1903 | ); |
| 1904 | assert.propertyVal( | |
| 1905 | (yield* serverSettings.getSettings).providerInstances[instanceId]?.config, | |
| 1906 | "apiKey", | |
| 1907 | "config-kept", | |
| 1908 | ); | |
| 1753 | 1909 | |
| 1754 | 1910 | const failed = yield* serverSettings |
| 1755 | 1911 | .updateProviderInstance({ operation: "remove", instanceId }) |
| ... | ... | @@ -1760,6 +1916,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1760 | 1916 | ?.value, |
| 1761 | 1917 | "sk-kept", |
| 1762 | 1918 | ); |
| 1919 | assert.propertyVal( | |
| 1920 | (yield* serverSettings.getSettings).providerInstances[instanceId]?.config, | |
| 1921 | "apiKey", | |
| 1922 | "config-kept", | |
| 1923 | ); | |
| 1763 | 1924 | }).pipe(Effect.provide(layerSettings)); |
| 1764 | 1925 | }), |
| 1765 | 1926 | ); |
| ... | ... | @@ -1841,9 +2002,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1841 | 2002 | yield* serverSettings.updateSettings({ |
| 1842 | 2003 | providerInstances: { |
| 1843 | 2004 | [instanceId]: { |
| 1844 | driver: ProviderDriverKind.make("codex"), | |
| 2005 | driver: ProviderDriverKind.make("antigravity"), | |
| 1845 | 2006 | environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }], |
| 1846 | config: {}, | |
| 2007 | config: { apiKey: "config-kept" }, | |
| 1847 | 2008 | }, |
| 1848 | 2009 | }, |
| 1849 | 2010 | }); |
| ... | ... | @@ -1853,9 +2014,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1853 | 2014 | .updateSettings({ |
| 1854 | 2015 | providerInstances: { |
| 1855 | 2016 | [instanceId]: { |
| 1856 | driver: ProviderDriverKind.make("codex"), | |
| 2017 | driver: ProviderDriverKind.make("antigravity"), | |
| 1857 | 2018 | environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }], |
| 1858 | config: {}, | |
| 2019 | config: { apiKey: "config-new" }, | |
| 1859 | 2020 | }, |
| 1860 | 2021 | }, |
| 1861 | 2022 | }) |
| ... | ... | @@ -1868,6 +2029,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1868 | 2029 | ?.value, |
| 1869 | 2030 | "sk-kept", |
| 1870 | 2031 | ); |
| 2032 | assert.propertyVal( | |
| 2033 | (yield* serverSettings.getSettings).providerInstances[instanceId]?.config, | |
| 2034 | "apiKey", | |
| 2035 | "config-kept", | |
| 2036 | ); | |
| 1871 | 2037 | }).pipe(Effect.provide(layerSettings)); |
| 1872 | 2038 | }, |
| 1873 | 2039 | ); |
apps/server/src/serverSettings.ts+111-355| ... | ... | @@ -17,8 +17,6 @@ import { |
| 17 | 17 | ProjectScript, |
| 18 | 18 | ProjectSettingsOverrides, |
| 19 | 19 | type ProviderInstanceConfig, |
| 20 | type ProviderInstanceEnvironmentVariable, | |
| 21 | type UsageLimitSourceConfig, | |
| 22 | 20 | type ProviderInstanceMutation, |
| 23 | 21 | defaultInstanceIdForDriver, |
| 24 | 22 | isProviderDriverKind, |
| ... | ... | @@ -64,12 +62,11 @@ import { |
| 64 | 62 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 65 | 63 | import { |
| 66 | 64 | readSecretFieldValues, |
| 67 | SOURCE_CONTROL_HOST_SECRET_FIELDS, | |
| 68 | type SourceControlHostSecretSlot, | |
| 69 | 65 | sourceControlHostSecretName, |
| 70 | writeSecretFieldValues, | |
| 71 | 66 | } from "./sourceControl/sourceControlHostSecrets.ts"; |
| 72 | 67 | |
| 68 | import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; | |
| 69 | ||
| 73 | 70 | export { |
| 74 | 71 | resolveReviewGuideModelSelection, |
| 75 | 72 | resolveSourceControlWriterModelSelection, |
| ... | ... | @@ -147,106 +144,12 @@ const normalizeServerSettings = ( |
| 147 | 144 | ), |
| 148 | 145 | ); |
| 149 | 146 | |
| 150 | function providerEnvironmentSecretName(input: { | |
| 151 | readonly instanceId: string; | |
| 152 | readonly name: string; | |
| 153 | }): string { | |
| 154 | return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * On disk a hub key or Bitbucket token is replaced by this marker and the | |
| 159 | * real value lives in the secret store, mirroring provider environment | |
| 160 | * secrets. A client that sends the marker back means "keep what you have". | |
| 161 | */ | |
| 162 | const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; | |
| 163 | ||
| 164 | function usageLimitSourceSecretName(sourceId: string): string { | |
| 165 | return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; | |
| 166 | } | |
| 167 | ||
| 168 | const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); | |
| 169 | ||
| 170 | /** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */ | |
| 171 | function mapSlotValues( | |
| 172 | hosts: ServerSettings["sourceControlHosts"], | |
| 173 | map: (slot: SourceControlHostSecretSlot, value: string) => string, | |
| 174 | ): ServerSettings["sourceControlHosts"] { | |
| 175 | return Object.fromEntries( | |
| 176 | Object.entries(hosts).map(([kind, fields]) => { | |
| 177 | const secretFields = SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []; | |
| 178 | if (secretFields.length === 0) return [kind, fields]; | |
| 179 | const next = { ...fields }; | |
| 180 | for (const field of secretFields) { | |
| 181 | if (!(field in next)) continue; | |
| 182 | next[field] = writeSecretFieldValues( | |
| 183 | next[field], | |
| 184 | readSecretFieldValues(next[field]).map( | |
| 185 | ([serverHost, value]) => [serverHost, map({ kind, field, serverHost }, value)] as const, | |
| 186 | ), | |
| 187 | ); | |
| 188 | } | |
| 189 | return [kind, next]; | |
| 190 | }), | |
| 191 | ); | |
| 192 | } | |
| 193 | ||
| 194 | /** Every saved secret slot in `hosts`, with its current value. */ | |
| 195 | function sourceControlHostSecretSlots( | |
| 196 | hosts: ServerSettings["sourceControlHosts"], | |
| 197 | ): ReadonlyArray<SourceControlHostSecretSlot & { readonly value: string }> { | |
| 198 | return Object.entries(hosts).flatMap(([kind, fields]) => | |
| 199 | (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => | |
| 200 | readSecretFieldValues(fields[field]).map(([serverHost, value]) => ({ | |
| 201 | kind, | |
| 202 | field, | |
| 203 | serverHost, | |
| 204 | value, | |
| 205 | })), | |
| 206 | ), | |
| 207 | ); | |
| 208 | } | |
| 209 | ||
| 210 | function redactProviderEnvironmentVariable( | |
| 211 | variable: ProviderInstanceEnvironmentVariable, | |
| 212 | ): ProviderInstanceEnvironmentVariable { | |
| 213 | if (!variable.sensitive) { | |
| 214 | const { valueRedacted: _omit, ...rest } = variable; | |
| 215 | return rest; | |
| 216 | } | |
| 217 | return { | |
| 218 | ...variable, | |
| 219 | value: "", | |
| 220 | ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}), | |
| 221 | }; | |
| 222 | } | |
| 223 | ||
| 224 | 147 | export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { |
| 225 | const providerInstances = Object.fromEntries( | |
| 226 | Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ | |
| 227 | instanceId, | |
| 228 | instance.environment | |
| 229 | ? { | |
| 230 | ...instance, | |
| 231 | environment: instance.environment.map(redactProviderEnvironmentVariable), | |
| 232 | } | |
| 233 | : instance, | |
| 234 | ]), | |
| 235 | ); | |
| 236 | // The hub key is a bearer secret; clients only need to know one is set. | |
| 237 | const usageLimitSources = Object.fromEntries( | |
| 238 | Object.entries(settings.usageLimitSources).map(([id, source]) => [ | |
| 239 | id, | |
| 240 | { | |
| 241 | ...source, | |
| 242 | managementKey: redactSecret(source.managementKey), | |
| 243 | }, | |
| 244 | ]), | |
| 245 | ); | |
| 246 | const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) => | |
| 247 | redactSecret(value), | |
| 148 | return mapSettingsSecrets(settings, (secret) => | |
| 149 | secret.sensitive | |
| 150 | ? { value: "", redacted: secret.redacted || secret.value.length > 0 } | |
| 151 | : { value: secret.value, redacted: false }, | |
| 248 | 152 | ); |
| 249 | return { ...settings, providerInstances, usageLimitSources, sourceControlHosts }; | |
| 250 | 153 | } |
| 251 | 154 | |
| 252 | 155 | export function applyProviderInstanceMutation( |
| ... | ... | @@ -781,35 +684,36 @@ const make = Effect.gen(function* () { |
| 781 | 684 | ); |
| 782 | 685 | |
| 783 | 686 | /** |
| 784 | * Moves source control host secrets hand-edited into settings.json into the secret store as | |
| 785 | * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps | |
| 786 | * working from the file and the move is retried on the next load. | |
| 687 | * Migrate plaintext schema-declared credentials on load. Failed moves stay usable from | |
| 688 | * the file and are retried next time; client redaction does not depend on migration. | |
| 787 | 689 | */ |
| 788 | const moveInlineSourceControlHostSecrets = (settings: ServerSettings) => | |
| 690 | const moveInlineSettingsSecrets = (settings: ServerSettings) => | |
| 789 | 691 | Effect.gen(function* () { |
| 692 | const pending: SettingsSecret[] = []; | |
| 693 | mapSettingsSecrets(settings, (secret) => { | |
| 694 | if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) { | |
| 695 | pending.push(secret); | |
| 696 | } | |
| 697 | return secret; | |
| 698 | }); | |
| 790 | 699 | const moved = new Set<string>(); |
| 791 | for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { | |
| 792 | if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue; | |
| 700 | for (const secret of pending) { | |
| 793 | 701 | const stored = yield* secretStore |
| 794 | .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value)) | |
| 702 | .set(secret.secretName, textEncoder.encode(secret.value)) | |
| 795 | 703 | .pipe( |
| 796 | 704 | Effect.as(true), |
| 797 | 705 | Effect.catch(() => |
| 798 | Effect.logWarning("failed to move a source control secret into the secret store", { | |
| 799 | kind: slot.kind, | |
| 800 | field: slot.field, | |
| 801 | }).pipe(Effect.as(false)), | |
| 706 | Effect.logWarning("failed to move a settings secret into the secret store").pipe( | |
| 707 | Effect.as(false), | |
| 708 | ), | |
| 802 | 709 | ), |
| 803 | 710 | ); |
| 804 | if (stored) moved.add(sourceControlHostSecretName(slot)); | |
| 711 | if (stored) moved.add(secret.secretName); | |
| 805 | 712 | } |
| 806 | 713 | if (moved.size === 0) return settings; |
| 807 | return { | |
| 808 | ...settings, | |
| 809 | sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => | |
| 810 | moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value, | |
| 811 | ), | |
| 812 | }; | |
| 714 | return mapSettingsSecrets(settings, (secret) => | |
| 715 | moved.has(secret.secretName) ? { value: "", redacted: true } : secret, | |
| 716 | ); | |
| 813 | 717 | }); |
| 814 | 718 | |
| 815 | 719 | const loadSettingsFromDisk = Effect.gen(function* () { |
| ... | ... | @@ -934,7 +838,7 @@ const make = Effect.gen(function* () { |
| 934 | 838 | : { settings: folded, secretMoves: [] }; |
| 935 | 839 | // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. |
| 936 | 840 | const migrated = settingsFileTrusted |
| 937 | ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings) | |
| 841 | ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings) | |
| 938 | 842 | : folded; |
| 939 | 843 | // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten |
| 940 | 844 | // once so the migrated values persist and the old keys disappear. |
| ... | ... | @@ -962,96 +866,49 @@ const make = Effect.gen(function* () { |
| 962 | 866 | |
| 963 | 867 | const getSettingsFromCache = Cache.get(settingsCache, cacheKey); |
| 964 | 868 | |
| 965 | const materializeProviderEnvironmentSecrets = ( | |
| 869 | const materializeSettingsSecrets = ( | |
| 966 | 870 | settings: ServerSettings, |
| 967 | 871 | ): Effect.Effect<ServerSettings, ServerSettingsError> => |
| 968 | 872 | Effect.gen(function* () { |
| 969 | const providerInstances: Record<string, ProviderInstanceConfig> = { | |
| 970 | ...settings.providerInstances, | |
| 971 | }; | |
| 972 | for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { | |
| 973 | if (!instance.environment) continue; | |
| 974 | const environment: ProviderInstanceEnvironmentVariable[] = []; | |
| 975 | for (const variable of instance.environment) { | |
| 976 | if (!variable.sensitive || !variable.valueRedacted) { | |
| 977 | environment.push(variable); | |
| 978 | continue; | |
| 979 | } | |
| 980 | const secret = yield* secretStore | |
| 981 | .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) | |
| 982 | .pipe( | |
| 983 | Effect.mapError( | |
| 984 | (cause) => | |
| 985 | new ServerSettingsError({ | |
| 986 | settingsPath, | |
| 987 | operation: "read-secret", | |
| 988 | providerInstanceId: instanceId, | |
| 989 | environmentVariable: variable.name, | |
| 990 | cause, | |
| 991 | }), | |
| 992 | ), | |
| 993 | ); | |
| 994 | environment.push({ | |
| 995 | ...variable, | |
| 996 | value: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", | |
| 997 | }); | |
| 998 | } | |
| 999 | providerInstances[instanceId] = { | |
| 1000 | ...instance, | |
| 1001 | environment, | |
| 1002 | } satisfies ProviderInstanceConfig; | |
| 1003 | } | |
| 1004 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; | |
| 1005 | for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { | |
| 1006 | if (source.managementKey !== SECRET_REDACTED) { | |
| 1007 | usageLimitSources[sourceId] = source; | |
| 1008 | continue; | |
| 1009 | } | |
| 1010 | const secret = yield* secretStore | |
| 1011 | .get(usageLimitSourceSecretName(sourceId)) | |
| 1012 | .pipe( | |
| 1013 | Effect.mapError( | |
| 1014 | (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), | |
| 1015 | ), | |
| 1016 | ); | |
| 1017 | usageLimitSources[sourceId] = { | |
| 1018 | ...source, | |
| 1019 | managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", | |
| 1020 | }; | |
| 1021 | } | |
| 1022 | 873 | const storedSecrets = new Map<string, string>(); |
| 1023 | for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { | |
| 1024 | if (slot.value !== SECRET_REDACTED) continue; | |
| 1025 | const secret = yield* secretStore | |
| 1026 | .get(sourceControlHostSecretName(slot)) | |
| 1027 | .pipe( | |
| 1028 | Effect.mapError( | |
| 1029 | (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), | |
| 1030 | ), | |
| 1031 | ); | |
| 874 | const pending = new Map<string, SettingsSecret>(); | |
| 875 | mapSettingsSecrets(settings, (secret) => { | |
| 876 | if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret); | |
| 877 | return secret; | |
| 878 | }); | |
| 879 | for (const secret of pending.values()) { | |
| 880 | const value = yield* secretStore.get(secret.secretName).pipe( | |
| 881 | Effect.mapError( | |
| 882 | (cause) => | |
| 883 | new ServerSettingsError({ | |
| 884 | settingsPath, | |
| 885 | operation: "read-secret", | |
| 886 | providerInstanceId: secret.providerInstanceId, | |
| 887 | environmentVariable: secret.environmentVariable, | |
| 888 | cause, | |
| 889 | }), | |
| 890 | ), | |
| 891 | ); | |
| 1032 | 892 | storedSecrets.set( |
| 1033 | sourceControlHostSecretName(slot), | |
| 1034 | Option.isSome(secret) ? textDecoder.decode(secret.value) : "", | |
| 893 | secret.secretName, | |
| 894 | Option.isSome(value) ? textDecoder.decode(value.value) : "", | |
| 1035 | 895 | ); |
| 1036 | 896 | } |
| 1037 | return { | |
| 1038 | ...settings, | |
| 1039 | providerInstances: providerInstances as ServerSettings["providerInstances"], | |
| 1040 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], | |
| 1041 | sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => | |
| 1042 | value === SECRET_REDACTED | |
| 1043 | ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "") | |
| 1044 | : value, | |
| 1045 | ), | |
| 1046 | }; | |
| 897 | return mapSettingsSecrets(settings, (secret) => ({ | |
| 898 | value: | |
| 899 | secret.sensitive && secret.redacted | |
| 900 | ? (storedSecrets.get(secret.secretName) ?? "") | |
| 901 | : secret.value, | |
| 902 | redacted: secret.sensitive && secret.kind === "environment" && secret.redacted, | |
| 903 | })); | |
| 1047 | 904 | }); |
| 1048 | 905 | |
| 1049 | 906 | const materializeChanges = (changes: Stream.Stream<ServerSettings>) => |
| 1050 | 907 | changes.pipe( |
| 1051 | 908 | Stream.mapEffect((settings) => |
| 1052 | materializeProviderEnvironmentSecrets(settings).pipe( | |
| 909 | materializeSettingsSecrets(settings).pipe( | |
| 1053 | 910 | Effect.catch((error: ServerSettingsError) => |
| 1054 | Effect.logWarning("failed to materialize provider environment secrets", { | |
| 911 | Effect.logWarning("failed to materialize settings secrets", { | |
| 1055 | 912 | operation: error.operation, |
| 1056 | 913 | providerInstanceId: error.providerInstanceId, |
| 1057 | 914 | environmentVariable: error.environmentVariable, |
| ... | ... | @@ -1072,162 +929,65 @@ const make = Effect.gen(function* () { |
| 1072 | 929 | | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" } |
| 1073 | 930 | ); |
| 1074 | 931 | |
| 1075 | const persistProviderEnvironmentSecrets = (current: ServerSettings, next: ServerSettings) => | |
| 932 | const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) => | |
| 1076 | 933 | Effect.sync(() => { |
| 1077 | const providerInstances: Record<string, ProviderInstanceConfig> = { | |
| 1078 | ...next.providerInstances, | |
| 1079 | }; | |
| 934 | const previous = new Map<string, SettingsSecret>(); | |
| 935 | mapSettingsSecrets(current, (secret) => { | |
| 936 | // Duplicate environment names follow the provider's last-value-wins behavior. | |
| 937 | previous.set(secret.secretName, secret); | |
| 938 | return secret; | |
| 939 | }); | |
| 1080 | 940 | const changes: SecretChange[] = []; |
| 1081 | ||
| 1082 | const nextSecretKeys = new Set<string>(); | |
| 1083 | for (const [instanceId, instance] of Object.entries(next.providerInstances)) { | |
| 1084 | if (!instance.environment) continue; | |
| 1085 | const environment: ProviderInstanceEnvironmentVariable[] = []; | |
| 1086 | for (const variable of instance.environment) { | |
| 1087 | const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); | |
| 1088 | if (!variable.sensitive) { | |
| 1089 | changes.push({ | |
| 1090 | kind: "remove", | |
| 1091 | secretName, | |
| 1092 | operation: "remove-secret", | |
| 1093 | providerInstanceId: instanceId, | |
| 1094 | environmentVariable: variable.name, | |
| 1095 | }); | |
| 1096 | environment.push(redactProviderEnvironmentVariable(variable)); | |
| 1097 | continue; | |
| 1098 | } | |
| 1099 | ||
| 1100 | nextSecretKeys.add(secretName); | |
| 1101 | // Match the provider environment's last-value-wins behavior for duplicate names. | |
| 1102 | const previous = variable.valueRedacted | |
| 1103 | ? current.providerInstances[ProviderInstanceId.make(instanceId)]?.environment?.findLast( | |
| 1104 | (entry) => entry.name === variable.name, | |
| 1105 | ) | |
| 1106 | : undefined; | |
| 1107 | const inlineValue = | |
| 1108 | previous?.sensitive && !previous.valueRedacted && previous.value.length > 0 | |
| 1109 | ? previous.value | |
| 1110 | : undefined; | |
| 1111 | const value = inlineValue ?? variable.value; | |
| 1112 | if (!variable.valueRedacted || inlineValue !== undefined) { | |
| 1113 | if (value.length > 0) { | |
| 1114 | changes.push({ | |
| 1115 | kind: "write", | |
| 1116 | secretName, | |
| 1117 | value: textEncoder.encode(value), | |
| 1118 | providerInstanceId: instanceId, | |
| 1119 | environmentVariable: variable.name, | |
| 1120 | }); | |
| 1121 | environment.push({ ...variable, value: "", valueRedacted: true }); | |
| 1122 | } else { | |
| 1123 | changes.push({ | |
| 1124 | kind: "remove", | |
| 1125 | secretName, | |
| 1126 | operation: "remove-secret", | |
| 1127 | providerInstanceId: instanceId, | |
| 1128 | environmentVariable: variable.name, | |
| 1129 | }); | |
| 1130 | const { valueRedacted: _omit, ...rest } = variable; | |
| 1131 | environment.push(rest); | |
| 1132 | } | |
| 1133 | continue; | |
| 1134 | } | |
| 1135 | ||
| 1136 | environment.push(redactProviderEnvironmentVariable(variable)); | |
| 1137 | } | |
| 1138 | providerInstances[instanceId] = { | |
| 1139 | ...instance, | |
| 1140 | environment, | |
| 1141 | } satisfies ProviderInstanceConfig; | |
| 1142 | } | |
| 1143 | ||
| 1144 | for (const [instanceId, instance] of Object.entries(current.providerInstances)) { | |
| 1145 | for (const variable of instance.environment ?? []) { | |
| 1146 | if (!variable.sensitive) continue; | |
| 1147 | const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); | |
| 1148 | if (nextSecretKeys.has(secretName)) continue; | |
| 1149 | changes.push({ | |
| 1150 | kind: "remove", | |
| 1151 | secretName, | |
| 1152 | operation: "remove-stale-secret", | |
| 1153 | providerInstanceId: instanceId, | |
| 1154 | environmentVariable: variable.name, | |
| 1155 | }); | |
| 941 | const nextSecretNames = new Set<string>(); | |
| 942 | const settings = mapSettingsSecrets(next, (secret) => { | |
| 943 | const metadata = { | |
| 944 | secretName: secret.secretName, | |
| 945 | ...(secret.providerInstanceId !== undefined | |
| 946 | ? { providerInstanceId: secret.providerInstanceId } | |
| 947 | : {}), | |
| 948 | ...(secret.environmentVariable !== undefined | |
| 949 | ? { environmentVariable: secret.environmentVariable } | |
| 950 | : {}), | |
| 951 | }; | |
| 952 | if (!secret.sensitive) { | |
| 953 | changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); | |
| 954 | return { value: secret.value, redacted: false }; | |
| 1156 | 955 | } |
| 1157 | } | |
| 1158 | ||
| 1159 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; | |
| 1160 | for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { | |
| 1161 | const secretName = usageLimitSourceSecretName(sourceId); | |
| 1162 | if (source.managementKey === SECRET_REDACTED) { | |
| 1163 | usageLimitSources[sourceId] = source; | |
| 1164 | continue; | |
| 956 | const old = previous.get(secret.secretName); | |
| 957 | const inlineValue = | |
| 958 | old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined; | |
| 959 | const value = secret.redacted ? inlineValue : secret.value; | |
| 960 | if (value === undefined) { | |
| 961 | nextSecretNames.add(secret.secretName); | |
| 962 | return { value: "", redacted: true }; | |
| 1165 | 963 | } |
| 1166 | if (source.managementKey.length === 0) { | |
| 1167 | changes.push({ kind: "remove", secretName, operation: "remove-secret" }); | |
| 1168 | usageLimitSources[sourceId] = source; | |
| 1169 | continue; | |
| 964 | if (value.length === 0) { | |
| 965 | changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); | |
| 966 | return { value: "", redacted: false }; | |
| 1170 | 967 | } |
| 1171 | changes.push({ | |
| 1172 | kind: "write", | |
| 1173 | secretName, | |
| 1174 | value: textEncoder.encode(source.managementKey), | |
| 1175 | }); | |
| 1176 | usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; | |
| 1177 | } | |
| 1178 | for (const sourceId of Object.keys(current.usageLimitSources)) { | |
| 1179 | if (sourceId in next.usageLimitSources) continue; | |
| 968 | nextSecretNames.add(secret.secretName); | |
| 969 | changes.push({ ...metadata, kind: "write", value: textEncoder.encode(value) }); | |
| 970 | return { value: "", redacted: true }; | |
| 971 | }); | |
| 972 | for (const secret of previous.values()) { | |
| 973 | if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue; | |
| 974 | if (changes.some((change) => change.secretName === secret.secretName)) continue; | |
| 1180 | 975 | changes.push({ |
| 1181 | 976 | kind: "remove", |
| 1182 | secretName: usageLimitSourceSecretName(sourceId), | |
| 977 | secretName: secret.secretName, | |
| 1183 | 978 | operation: "remove-stale-secret", |
| 979 | ...(secret.providerInstanceId !== undefined | |
| 980 | ? { providerInstanceId: secret.providerInstanceId } | |
| 981 | : {}), | |
| 982 | ...(secret.environmentVariable !== undefined | |
| 983 | ? { environmentVariable: secret.environmentVariable } | |
| 984 | : {}), | |
| 1184 | 985 | }); |
| 1185 | 986 | } |
| 1186 | ||
| 1187 | // Hand-edited plaintext not yet in the store, so the redaction marker can move it there. | |
| 1188 | const inlineSecrets = new Map( | |
| 1189 | sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) => | |
| 1190 | slot.value.length > 0 && slot.value !== SECRET_REDACTED | |
| 1191 | ? [[sourceControlHostSecretName(slot), slot.value] as const] | |
| 1192 | : [], | |
| 1193 | ), | |
| 1194 | ); | |
| 1195 | const nextSecretNames = new Set<string>(); | |
| 1196 | const sourceControlHosts = mapSlotValues(next.sourceControlHosts, (slot, raw) => { | |
| 1197 | const secretName = sourceControlHostSecretName(slot); | |
| 1198 | // The marker keeps what is saved; a hand-edited plaintext value moves into the store. | |
| 1199 | const value = raw === SECRET_REDACTED ? inlineSecrets.get(secretName) : raw; | |
| 1200 | if (value === undefined) { | |
| 1201 | nextSecretNames.add(secretName); | |
| 1202 | return SECRET_REDACTED; | |
| 1203 | } | |
| 1204 | if (value.length === 0) { | |
| 1205 | changes.push({ kind: "remove", secretName, operation: "remove-secret" }); | |
| 1206 | return ""; | |
| 1207 | } | |
| 1208 | nextSecretNames.add(secretName); | |
| 1209 | changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); | |
| 1210 | return SECRET_REDACTED; | |
| 1211 | }); | |
| 1212 | for (const slot of sourceControlHostSecretSlots(current.sourceControlHosts)) { | |
| 1213 | const secretName = sourceControlHostSecretName(slot); | |
| 1214 | if (nextSecretNames.has(secretName)) continue; | |
| 1215 | if (changes.some((change) => change.secretName === secretName)) continue; | |
| 1216 | changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" }); | |
| 1217 | } | |
| 1218 | ||
| 1219 | return { | |
| 1220 | settings: { | |
| 1221 | ...next, | |
| 1222 | providerInstances: providerInstances as ServerSettings["providerInstances"], | |
| 1223 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], | |
| 1224 | sourceControlHosts, | |
| 1225 | }, | |
| 1226 | changes, | |
| 1227 | }; | |
| 987 | return { settings, changes }; | |
| 1228 | 988 | }); |
| 1229 | 989 | |
| 1230 | const rollbackProviderEnvironmentSecretWrites = ( | |
| 990 | const rollbackSettingsSecretWrites = ( | |
| 1231 | 991 | writes: ReadonlyArray<{ |
| 1232 | 992 | readonly secretName: string; |
| 1233 | 993 | readonly previousValue: Option.Option<Uint8Array>; |
| ... | ... | @@ -1243,7 +1003,7 @@ const make = Effect.gen(function* () { |
| 1243 | 1003 | : secretStore.remove(write.secretName) |
| 1244 | 1004 | ).pipe( |
| 1245 | 1005 | Effect.catch((cause) => |
| 1246 | Effect.logWarning("failed to roll back provider environment secret", { | |
| 1006 | Effect.logWarning("failed to roll back settings secret", { | |
| 1247 | 1007 | providerInstanceId: write.providerInstanceId, |
| 1248 | 1008 | environmentVariable: write.environmentVariable, |
| 1249 | 1009 | cause, |
| ... | ... | @@ -1253,14 +1013,14 @@ const make = Effect.gen(function* () { |
| 1253 | 1013 | { discard: true }, |
| 1254 | 1014 | ); |
| 1255 | 1015 | |
| 1256 | const applyProviderEnvironmentSecretChanges = (changes: ReadonlyArray<SecretChange>) => { | |
| 1016 | const applySettingsSecretChanges = (changes: ReadonlyArray<SecretChange>) => { | |
| 1257 | 1017 | const applied: Array<{ |
| 1258 | 1018 | readonly secretName: string; |
| 1259 | 1019 | readonly previousValue: Option.Option<Uint8Array>; |
| 1260 | 1020 | readonly providerInstanceId?: string; |
| 1261 | 1021 | readonly environmentVariable?: string; |
| 1262 | 1022 | }> = []; |
| 1263 | const rollback = Effect.suspend(() => rollbackProviderEnvironmentSecretWrites(applied)); | |
| 1023 | const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied)); | |
| 1264 | 1024 | return Effect.forEach( |
| 1265 | 1025 | changes, |
| 1266 | 1026 | (change) => |
| ... | ... | @@ -1310,16 +1070,12 @@ const make = Effect.gen(function* () { |
| 1310 | 1070 | Effect.gen(function* () { |
| 1311 | 1071 | const current = yield* getSettingsFromCache; |
| 1312 | 1072 | const updated = yield* update(current); |
| 1313 | const persisted = yield* persistProviderEnvironmentSecrets(current, updated); | |
| 1073 | const persisted = yield* persistSettingsSecrets(current, updated); | |
| 1314 | 1074 | const next = yield* normalizeServerSettings(persisted.settings); |
| 1315 | 1075 | const materialized = yield* Effect.uninterruptibleMask(() => |
| 1316 | 1076 | Effect.gen(function* () { |
| 1317 | const rollbackSecretChanges = yield* applyProviderEnvironmentSecretChanges( | |
| 1318 | persisted.changes, | |
| 1319 | ); | |
| 1320 | const materializedExit = yield* Effect.exit( | |
| 1321 | materializeProviderEnvironmentSecrets(next), | |
| 1322 | ); | |
| 1077 | const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); | |
| 1078 | const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); | |
| 1323 | 1079 | if (Exit.isFailure(materializedExit)) { |
| 1324 | 1080 | yield* rollbackSecretChanges; |
| 1325 | 1081 | return yield* Effect.failCause(materializedExit.cause); |
| ... | ... | @@ -1341,7 +1097,7 @@ const make = Effect.gen(function* () { |
| 1341 | 1097 | const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => |
| 1342 | 1098 | writeSemaphore.withPermits(1)( |
| 1343 | 1099 | getSettingsFromCache.pipe( |
| 1344 | Effect.flatMap(materializeProviderEnvironmentSecrets), | |
| 1100 | Effect.flatMap(materializeSettingsSecrets), | |
| 1345 | 1101 | Effect.map(resolveTextGenerationProvider), |
| 1346 | 1102 | Effect.flatMap(use), |
| 1347 | 1103 | ), |
| ... | ... | @@ -1457,7 +1213,7 @@ const make = Effect.gen(function* () { |
| 1457 | 1213 | start, |
| 1458 | 1214 | ready: Deferred.await(startedDeferred), |
| 1459 | 1215 | getSettings: getSettingsFromCache.pipe( |
| 1460 | Effect.flatMap(materializeProviderEnvironmentSecrets), | |
| 1216 | Effect.flatMap(materializeSettingsSecrets), | |
| 1461 | 1217 | Effect.map(resolveTextGenerationProvider), |
| 1462 | 1218 | ), |
| 1463 | 1219 | updateSettings: (patch) => |
apps/server/src/settingsSecrets.ts created+150| ... | ... | @@ -0,0 +1,150 @@ |
| 1 | import { | |
| 2 | AntigravitySettings, | |
| 3 | ClaudeSettings, | |
| 4 | CodexSettings, | |
| 5 | type ServerSettings, | |
| 6 | UsageLimitSourceConfig, | |
| 7 | secretSettingsFields, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; | |
| 10 | import { cursorClient } from "@t3tools/provider-cursor/client"; | |
| 11 | import { grokClient } from "@t3tools/provider-grok/client"; | |
| 12 | import { museClient } from "@t3tools/provider-muse/client"; | |
| 13 | import { openCodeClient } from "@t3tools/provider-opencode/client"; | |
| 14 | import { piClient } from "@t3tools/provider-pi/client"; | |
| 15 | import { | |
| 16 | readSecretFieldValues, | |
| 17 | SOURCE_CONTROL_HOST_SECRET_FIELDS, | |
| 18 | sourceControlHostSecretName, | |
| 19 | writeSecretFieldValues, | |
| 20 | } from "./sourceControl/sourceControlHostSecrets.ts"; | |
| 21 | ||
| 22 | export const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; | |
| 23 | ||
| 24 | const providerSecretFields = new Map([ | |
| 25 | ["codex", secretSettingsFields(CodexSettings)], | |
| 26 | ["claudeAgent", secretSettingsFields(ClaudeSettings)], | |
| 27 | ["antigravity", secretSettingsFields(AntigravitySettings)], | |
| 28 | ...[cursorClient, grokClient, museClient, openCodeClient, piClient, acpRegistryClient].map( | |
| 29 | (definition) => | |
| 30 | [definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const, | |
| 31 | ), | |
| 32 | ]); | |
| 33 | const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); | |
| 34 | const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); | |
| 35 | ||
| 36 | export interface SettingsSecret { | |
| 37 | readonly secretName: string; | |
| 38 | readonly kind: "field" | "environment"; | |
| 39 | readonly value: string; | |
| 40 | readonly redacted: boolean; | |
| 41 | readonly sensitive: boolean; | |
| 42 | readonly providerInstanceId?: string; | |
| 43 | readonly environmentVariable?: string; | |
| 44 | } | |
| 45 | ||
| 46 | type SecretValue = Pick<SettingsSecret, "value" | "redacted">; | |
| 47 | ||
| 48 | /** | |
| 49 | * Visits credential fields declared by schemas and dynamically sensitive environment variables. | |
| 50 | * Store keys and client representations stay here; callers supply only the secret operation. | |
| 51 | */ | |
| 52 | export function mapSettingsSecrets( | |
| 53 | settings: ServerSettings, | |
| 54 | map: (secret: SettingsSecret) => SecretValue, | |
| 55 | ): ServerSettings { | |
| 56 | const mapFields = ( | |
| 57 | fields: Readonly<Record<string, unknown>>, | |
| 58 | secretFields: ReadonlyArray<string>, | |
| 59 | name: (field: string, serverHost: string | null) => string, | |
| 60 | ) => { | |
| 61 | const next = { ...fields }; | |
| 62 | for (const field of secretFields) { | |
| 63 | if (!(field in next)) continue; | |
| 64 | next[field] = writeSecretFieldValues( | |
| 65 | next[field], | |
| 66 | readSecretFieldValues(next[field]).map(([serverHost, value]) => { | |
| 67 | const result = map({ | |
| 68 | secretName: name(field, serverHost), | |
| 69 | kind: "field", | |
| 70 | value, | |
| 71 | redacted: value === SECRET_REDACTED, | |
| 72 | sensitive: true, | |
| 73 | }); | |
| 74 | return [serverHost, result.redacted ? SECRET_REDACTED : result.value] as const; | |
| 75 | }), | |
| 76 | ); | |
| 77 | } | |
| 78 | return next; | |
| 79 | }; | |
| 80 | ||
| 81 | const providerInstances = Object.fromEntries( | |
| 82 | Object.entries(settings.providerInstances).map(([instanceId, instance]) => { | |
| 83 | const config = instance.config; | |
| 84 | return [ | |
| 85 | instanceId, | |
| 86 | { | |
| 87 | ...instance, | |
| 88 | ...(config !== null && typeof config === "object" && !Array.isArray(config) | |
| 89 | ? { | |
| 90 | config: mapFields( | |
| 91 | config as Record<string, unknown>, | |
| 92 | providerSecretFields.get(instance.driver) ?? [], | |
| 93 | (field, serverHost) => | |
| 94 | `provider-config-${encode(instanceId)}-${encode(instance.driver)}-${encode(field)}` + | |
| 95 | (serverHost === null ? "" : `-${encode(serverHost)}`), | |
| 96 | ), | |
| 97 | } | |
| 98 | : {}), | |
| 99 | ...(instance.environment | |
| 100 | ? { | |
| 101 | environment: instance.environment.map((variable) => { | |
| 102 | const result = map({ | |
| 103 | secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, | |
| 104 | kind: "environment", | |
| 105 | value: variable.value, | |
| 106 | redacted: variable.valueRedacted === true, | |
| 107 | sensitive: variable.sensitive, | |
| 108 | providerInstanceId: instanceId, | |
| 109 | environmentVariable: variable.name, | |
| 110 | }); | |
| 111 | const { valueRedacted: _omit, ...rest } = variable; | |
| 112 | return { | |
| 113 | ...rest, | |
| 114 | value: result.value, | |
| 115 | ...(result.redacted ? { valueRedacted: true } : {}), | |
| 116 | }; | |
| 117 | }), | |
| 118 | } | |
| 119 | : {}), | |
| 120 | }, | |
| 121 | ]; | |
| 122 | }), | |
| 123 | ); | |
| 124 | ||
| 125 | const sourceControlHosts = Object.fromEntries( | |
| 126 | Object.entries(settings.sourceControlHosts).map(([kind, fields]) => [ | |
| 127 | kind, | |
| 128 | mapFields(fields, SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? [], (field, serverHost) => | |
| 129 | sourceControlHostSecretName({ kind, field, serverHost }), | |
| 130 | ), | |
| 131 | ]), | |
| 132 | ) as ServerSettings["sourceControlHosts"]; | |
| 133 | ||
| 134 | const usageLimitSources = Object.fromEntries( | |
| 135 | Object.entries(settings.usageLimitSources).map(([id, source]) => [ | |
| 136 | id, | |
| 137 | mapFields( | |
| 138 | source, | |
| 139 | usageLimitSecretFields, | |
| 140 | (field, serverHost) => | |
| 141 | `usage-limit-source-${encode(id)}` + | |
| 142 | (field === "managementKey" && serverHost === null | |
| 143 | ? "" | |
| 144 | : `-${encode(field)}${serverHost === null ? "" : `-${encode(serverHost)}`}`), | |
| 145 | ), | |
| 146 | ]), | |
| 147 | ) as ServerSettings["usageLimitSources"]; | |
| 148 | ||
| 149 | return { ...settings, providerInstances, sourceControlHosts, usageLimitSources }; | |
| 150 | } |
apps/server/src/sourceControl/sourceControlHostSecrets.ts+3-7| ... | ... | @@ -7,12 +7,10 @@ |
| 7 | 7 | * |
| 8 | 8 | * @module sourceControl/sourceControlHostSecrets |
| 9 | 9 | */ |
| 10 | import { secretSettingsFields } from "@t3tools/contracts"; | |
| 10 | 11 | import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; |
| 11 | 12 | import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; |
| 12 | import { | |
| 13 | secretSourceControlHostSettingsFields, | |
| 14 | type SourceControlClientDefinition, | |
| 15 | } from "@t3tools/source-control-core/client/definition"; | |
| 13 | import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; | |
| 16 | 14 | import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; |
| 17 | 15 | import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; |
| 18 | 16 | import * as GitHub from "@t3tools/source-control-github/client/definition"; |
| ... | ... | @@ -33,9 +31,7 @@ export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< |
| 33 | 31 | ReadonlyArray<string> |
| 34 | 32 | > = new Map( |
| 35 | 33 | definitions.flatMap((definition) => { |
| 36 | const fields = definition.settings | |
| 37 | ? secretSourceControlHostSettingsFields(definition.settings) | |
| 38 | : []; | |
| 34 | const fields = definition.settings ? secretSettingsFields(definition.settings) : []; | |
| 39 | 35 | return fields.length > 0 ? [[definition.kind, fields] as const] : []; |
| 40 | 36 | }), |
| 41 | 37 | ); |
apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx+2-3| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | DialogTitle, |
| 14 | 14 | } from "../ui/dialog"; |
| 15 | 15 | import { Input } from "../ui/input"; |
| 16 | import { SecretInput } from "./SecretInput"; | |
| 16 | 17 | import { Label } from "../ui/label"; |
| 17 | 18 | |
| 18 | 19 | /** |
| ... | ... | @@ -118,10 +119,8 @@ export function AddUsageLimitSourceDialog({ |
| 118 | 119 | </div> |
| 119 | 120 | <div className="grid gap-1.5"> |
| 120 | 121 | <Label htmlFor="usage-source-key">Management key</Label> |
| 121 | <Input | |
| 122 | <SecretInput | |
| 122 | 123 | id="usage-source-key" |
| 123 | type="password" | |
| 124 | autoComplete="off" | |
| 125 | 124 | value={managementKey} |
| 126 | 125 | onChange={(event) => setManagementKey(event.target.value)} |
| 127 | 126 | /> |
apps/web/src/components/settings/GitHubTokenSettings.tsx+3-4| ... | ... | @@ -8,6 +8,7 @@ import { serverEnvironment } from "../../state/server"; |
| 8 | 8 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 9 | 9 | import { Button, InlineButton } from "../ui/button"; |
| 10 | 10 | import { Input } from "../ui/input"; |
| 11 | import { SecretInput } from "./SecretInput"; | |
| 11 | 12 | import { Label } from "../ui/label"; |
| 12 | 13 | |
| 13 | 14 | const DEFAULT_HOST = "github.com"; |
| ... | ... | @@ -103,12 +104,10 @@ export function GitHubTokenSettings({ |
| 103 | 104 | </div> |
| 104 | 105 | <div className="grid gap-1.5"> |
| 105 | 106 | <Label htmlFor={`github-token-${environmentId}`}>Token</Label> |
| 106 | <Input | |
| 107 | <SecretInput | |
| 107 | 108 | id={`github-token-${environmentId}`} |
| 108 | type="password" | |
| 109 | autoComplete="off" | |
| 110 | 109 | size="sm" |
| 111 | placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"} | |
| 110 | configured={isSaved} | |
| 112 | 111 | value={draft} |
| 113 | 112 | onChange={(event) => setDraft(event.target.value)} |
| 114 | 113 | /> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+49-28| ... | ... | @@ -44,6 +44,7 @@ import { Badge } from "../ui/badge"; |
| 44 | 44 | import { Button } from "../ui/button"; |
| 45 | 45 | import { MorphIcon } from "~/components/MorphIcon"; |
| 46 | 46 | import { DraftInput } from "../ui/draft-input"; |
| 47 | import { SecretInput } from "./SecretInput"; | |
| 47 | 48 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 48 | 49 | import { Switch } from "../ui/switch"; |
| 49 | 50 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| ... | ... | @@ -263,9 +264,6 @@ function ProviderEnvironmentFieldRow(props: { |
| 263 | 264 | }) { |
| 264 | 265 | const inputId = `${props.idPrefix}-environment-${props.field.name}`; |
| 265 | 266 | const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); |
| 266 | const placeholder = props.variable?.valueRedacted | |
| 267 | ? "Stored secret - enter a new value to replace" | |
| 268 | : props.field.placeholder; | |
| 269 | 267 | |
| 270 | 268 | return ( |
| 271 | 269 | <SettingsRow |
| ... | ... | @@ -273,17 +271,29 @@ function ProviderEnvironmentFieldRow(props: { |
| 273 | 271 | description={props.field.description} |
| 274 | 272 | control={ |
| 275 | 273 | <div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56"> |
| 276 | <DraftInput | |
| 277 | id={inputId} | |
| 278 | size="sm" | |
| 279 | className="min-w-0 flex-1" | |
| 280 | type={props.field.sensitive === false ? undefined : "password"} | |
| 281 | autoComplete="off" | |
| 282 | value={value} | |
| 283 | onCommit={(next) => props.onCommit(props.field, next)} | |
| 284 | placeholder={placeholder} | |
| 285 | spellCheck={false} | |
| 286 | /> | |
| 274 | {props.field.sensitive === false ? ( | |
| 275 | <DraftInput | |
| 276 | id={inputId} | |
| 277 | size="sm" | |
| 278 | className="min-w-0 flex-1" | |
| 279 | autoComplete="off" | |
| 280 | value={value} | |
| 281 | onCommit={(next) => props.onCommit(props.field, next)} | |
| 282 | placeholder={props.field.placeholder} | |
| 283 | spellCheck={false} | |
| 284 | /> | |
| 285 | ) : ( | |
| 286 | <SecretInput | |
| 287 | id={inputId} | |
| 288 | size="sm" | |
| 289 | className="min-w-0 flex-1" | |
| 290 | value={value} | |
| 291 | onCommit={(next) => props.onCommit(props.field, next)} | |
| 292 | placeholder={props.field.placeholder} | |
| 293 | configured={props.variable?.valueRedacted === true} | |
| 294 | spellCheck={false} | |
| 295 | /> | |
| 296 | )} | |
| 287 | 297 | {props.variable ? ( |
| 288 | 298 | <Button |
| 289 | 299 | type="button" |
| ... | ... | @@ -410,20 +420,31 @@ export function ProviderEnvironmentSection(props: { |
| 410 | 420 | <span className="hidden text-xs text-muted-foreground sm:inline" aria-hidden> |
| 411 | 421 | = |
| 412 | 422 | </span> |
| 413 | <DraftInput | |
| 414 | size="sm" | |
| 415 | font="mono" | |
| 416 | className="min-w-0 flex-1" | |
| 417 | value={variable.valueRedacted ? "" : variable.value} | |
| 418 | onCommit={(value) => updateVariable(variable.id, { value })} | |
| 419 | type={variable.sensitive ? "password" : undefined} | |
| 420 | autoComplete="off" | |
| 421 | placeholder={ | |
| 422 | variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value" | |
| 423 | } | |
| 424 | spellCheck={false} | |
| 425 | aria-label={`Environment variable value ${index + 1}`} | |
| 426 | /> | |
| 423 | {variable.sensitive ? ( | |
| 424 | <SecretInput | |
| 425 | size="sm" | |
| 426 | font="mono" | |
| 427 | className="min-w-0 flex-1" | |
| 428 | value={variable.valueRedacted ? "" : variable.value} | |
| 429 | onCommit={(value) => updateVariable(variable.id, { value })} | |
| 430 | placeholder="value" | |
| 431 | configured={variable.valueRedacted === true} | |
| 432 | spellCheck={false} | |
| 433 | aria-label={`Environment variable value ${index + 1}`} | |
| 434 | /> | |
| 435 | ) : ( | |
| 436 | <DraftInput | |
| 437 | size="sm" | |
| 438 | font="mono" | |
| 439 | className="min-w-0 flex-1" | |
| 440 | value={variable.valueRedacted ? "" : variable.value} | |
| 441 | onCommit={(value) => updateVariable(variable.id, { value })} | |
| 442 | autoComplete="off" | |
| 443 | placeholder="value" | |
| 444 | spellCheck={false} | |
| 445 | aria-label={`Environment variable value ${index + 1}`} | |
| 446 | /> | |
| 447 | )} | |
| 427 | 448 | <Tooltip> |
| 428 | 449 | <TooltipTrigger |
| 429 | 450 | render={ |
apps/web/src/components/settings/ProviderSettingsForm.tsx+28-28| ... | ... | @@ -3,11 +3,12 @@ |
| 3 | 3 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import * as Schema from "effect/Schema"; |
| 6 | import type { | |
| 7 | ProviderSettingsFormAnnotation, | |
| 8 | ProviderSettingsFormControl, | |
| 9 | ProviderSettingsFormOption, | |
| 10 | ProviderSettingsFormSchemaAnnotation, | |
| 6 | import { | |
| 7 | isSecretSetting, | |
| 8 | type ProviderSettingsFormAnnotation, | |
| 9 | type ProviderSettingsFormControl, | |
| 10 | type ProviderSettingsFormOption, | |
| 11 | type ProviderSettingsFormSchemaAnnotation, | |
| 11 | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | import { PlusIcon, XIcon } from "lucide-react"; |
| 13 | 14 | |
| ... | ... | @@ -15,6 +16,7 @@ import { cn } from "../../lib/utils"; |
| 15 | 16 | import { Button } from "../ui/button"; |
| 16 | 17 | import { DraftInput } from "../ui/draft-input"; |
| 17 | 18 | import { Input } from "../ui/input"; |
| 19 | import { SecretInput } from "./SecretInput"; | |
| 18 | 20 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 19 | 21 | import { Switch } from "../ui/switch"; |
| 20 | 22 | import { Textarea } from "../ui/textarea"; |
| ... | ... | @@ -131,7 +133,7 @@ export function deriveProviderSettingsFields( |
| 131 | 133 | ? { placeholder: formAnnotation.placeholder } |
| 132 | 134 | : {}), |
| 133 | 135 | clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", |
| 134 | ...(formAnnotation.secret ? { secret: true } : {}), | |
| 136 | ...(isSecretSetting(fieldSchema) ? { secret: true } : {}), | |
| 135 | 137 | ...(formAnnotation.control === "switch" |
| 136 | 138 | ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } |
| 137 | 139 | : {}), |
| ... | ... | @@ -255,19 +257,6 @@ function readProviderConfigString(config: unknown, key: string): string { |
| 255 | 257 | return typeof value === "string" ? value : ""; |
| 256 | 258 | } |
| 257 | 259 | |
| 258 | /** | |
| 259 | * A secret field never shows its saved value: it starts empty, and its placeholder says whether | |
| 260 | * one is saved. Saving one is typing a new value; clearing it is the host form's job. | |
| 261 | */ | |
| 262 | function secretFieldInputProps(field: ProviderSettingsFieldModel, config: unknown) { | |
| 263 | const saved = readProviderConfigString(config, field.key); | |
| 264 | if (!field.secret) return { value: saved, placeholder: field.placeholder }; | |
| 265 | return { | |
| 266 | value: "", | |
| 267 | placeholder: saved.length > 0 ? "Stored secret, enter a new value to replace" : "Not set", | |
| 268 | }; | |
| 269 | } | |
| 270 | ||
| 271 | 260 | function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { |
| 272 | 261 | if (config === null || typeof config !== "object") return defaultValue; |
| 273 | 262 | const value = (config as Record<string, unknown>)[key]; |
| ... | ... | @@ -423,6 +412,16 @@ function ProviderSettingsFieldRow({ |
| 423 | 412 | placeholder={field.placeholder} |
| 424 | 413 | spellCheck={false} |
| 425 | 414 | /> |
| 415 | ) : field.secret ? ( | |
| 416 | <SecretInput | |
| 417 | id={inputId} | |
| 418 | aria-describedby={descriptionId} | |
| 419 | size="sm" | |
| 420 | className="w-full max-w-full @min-[32rem]/settings-row:w-56" | |
| 421 | configured={readProviderConfigString(value, field.key).length > 0} | |
| 422 | value="" | |
| 423 | onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))} | |
| 424 | /> | |
| 426 | 425 | ) : ( |
| 427 | 426 | <DraftInput |
| 428 | 427 | id={inputId} |
| ... | ... | @@ -431,10 +430,9 @@ function ProviderSettingsFieldRow({ |
| 431 | 430 | className="w-full max-w-full @min-[32rem]/settings-row:w-56" |
| 432 | 431 | type={field.control === "password" ? "password" : undefined} |
| 433 | 432 | autoComplete={field.control === "password" ? "off" : undefined} |
| 434 | {...secretFieldInputProps(field, value)} | |
| 433 | value={readProviderConfigString(value, field.key)} | |
| 434 | placeholder={field.placeholder} | |
| 435 | 435 | onCommit={(next) => { |
| 436 | // An untouched saved secret commits empty; only a typed value replaces it. | |
| 437 | if (field.secret && next === "") return; | |
| 438 | 436 | onChange(nextProviderConfigWithFieldValue(value, field, next)); |
| 439 | 437 | }} |
| 440 | 438 | spellCheck={false} |
| ... | ... | @@ -515,27 +513,29 @@ function ProviderSettingsFieldRow({ |
| 515 | 513 | } |
| 516 | 514 | |
| 517 | 515 | const type = field.control === "password" ? "password" : undefined; |
| 516 | const BufferedInput = field.secret ? SecretInput : DraftInput; | |
| 517 | const ControlledInput = field.secret ? SecretInput : Input; | |
| 518 | 518 | return ( |
| 519 | 519 | <FieldFrame variant={variant}> |
| 520 | 520 | <label htmlFor={inputId} className={cn(variant === "card" && "block")}> |
| 521 | 521 | {label} |
| 522 | 522 | {variant === "card" ? ( |
| 523 | <DraftInput | |
| 523 | <BufferedInput | |
| 524 | 524 | id={inputId} |
| 525 | 525 | size="sm" |
| 526 | 526 | className="mt-1.5" |
| 527 | type={type} | |
| 528 | autoComplete={field.control === "password" ? "off" : undefined} | |
| 527 | type={field.secret ? undefined : type} | |
| 528 | autoComplete={!field.secret && field.control === "password" ? "off" : undefined} | |
| 529 | 529 | value={readProviderConfigString(value, field.key)} |
| 530 | 530 | onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))} |
| 531 | 531 | placeholder={field.placeholder} |
| 532 | 532 | spellCheck={false} |
| 533 | 533 | /> |
| 534 | 534 | ) : ( |
| 535 | <Input | |
| 535 | <ControlledInput | |
| 536 | 536 | id={inputId} |
| 537 | type={type} | |
| 538 | autoComplete={field.control === "password" ? "off" : undefined} | |
| 537 | type={field.secret ? undefined : type} | |
| 538 | autoComplete={!field.secret && field.control === "password" ? "off" : undefined} | |
| 539 | 539 | value={readProviderConfigString(value, field.key)} |
| 540 | 540 | onChange={(event) => |
| 541 | 541 | onChange(nextProviderConfigWithFieldValue(value, field, event.target.value)) |
apps/web/src/components/settings/SecretInput.test.tsx created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import { act } from "react"; | |
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { SecretInput } from "./SecretInput"; | |
| 6 | ||
| 7 | vi.mock("../ui/input", () => ({ | |
| 8 | Input: (props: Record<string, unknown>) => <input {...props} />, | |
| 9 | })); | |
| 10 | ||
| 11 | let renderer: ReactTestRenderer; | |
| 12 | ||
| 13 | beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); | |
| 14 | afterEach(async () => { | |
| 15 | await act(() => renderer?.unmount()); | |
| 16 | vi.unstubAllGlobals(); | |
| 17 | }); | |
| 18 | ||
| 19 | async function edit(value: string) { | |
| 20 | await act(() => renderer.root.findByType("input").props.onFocus()); | |
| 21 | await act(() => renderer.root.findByType("input").props.onChange({ target: { value } })); | |
| 22 | await act(() => renderer.root.findByType("input").props.onBlur()); | |
| 23 | } | |
| 24 | ||
| 25 | describe("SecretInput", () => { | |
| 26 | it("preserves a configured secret on empty edits and commits a replacement", async () => { | |
| 27 | const onCommit = vi.fn(); | |
| 28 | await act(() => { | |
| 29 | renderer = create(<SecretInput configured value="" onCommit={onCommit} />); | |
| 30 | }); | |
| 31 | await edit(""); | |
| 32 | expect(onCommit).not.toHaveBeenCalled(); | |
| 33 | await edit("replacement"); | |
| 34 | expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement"); | |
| 35 | expect(renderer.root.findByType("input").props.value).toBe(""); | |
| 36 | }); | |
| 37 | ||
| 38 | it("allows clearing an unsaved draft", async () => { | |
| 39 | const onCommit = vi.fn(); | |
| 40 | await act(() => { | |
| 41 | renderer = create(<SecretInput value="draft" onCommit={onCommit} />); | |
| 42 | }); | |
| 43 | await edit(""); | |
| 44 | expect(onCommit).toHaveBeenCalledExactlyOnceWith(""); | |
| 45 | }); | |
| 46 | ||
| 47 | it("keeps an in-progress replacement when settings refresh", async () => { | |
| 48 | const onCommit = vi.fn(); | |
| 49 | await act(() => { | |
| 50 | renderer = create(<SecretInput configured value="" onCommit={onCommit} />); | |
| 51 | }); | |
| 52 | await act(() => renderer.root.findByType("input").props.onFocus()); | |
| 53 | await act(() => | |
| 54 | renderer.root.findByType("input").props.onChange({ target: { value: "replacement" } }), | |
| 55 | ); | |
| 56 | await act(() => { | |
| 57 | renderer.update(<SecretInput configured value="" onCommit={onCommit} />); | |
| 58 | }); | |
| 59 | expect(renderer.root.findByType("input").props.value).toBe("replacement"); | |
| 60 | await act(() => renderer.root.findByType("input").props.onBlur()); | |
| 61 | expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement"); | |
| 62 | }); | |
| 63 | }); |
apps/web/src/components/settings/SecretInput.tsx created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import { DraftInput, type DraftInputProps } from "../ui/draft-input"; | |
| 2 | import { Input, type InputProps } from "../ui/input"; | |
| 3 | ||
| 4 | type SecretInputProps = Omit<InputProps, "type" | "autoComplete" | "defaultValue" | "value"> & { | |
| 5 | /** Whether the server already holds a value. Pass only the replacement draft as `value`. */ | |
| 6 | readonly configured?: boolean; | |
| 7 | readonly value: string; | |
| 8 | readonly onCommit?: DraftInputProps["onCommit"]; | |
| 9 | }; | |
| 10 | ||
| 11 | /** Shared presentation for write-only settings. Clearing a saved value belongs to its form. */ | |
| 12 | export function SecretInput({ | |
| 13 | configured = false, | |
| 14 | placeholder, | |
| 15 | onCommit, | |
| 16 | ...props | |
| 17 | }: SecretInputProps) { | |
| 18 | const shared = { | |
| 19 | ...props, | |
| 20 | type: "password" as const, | |
| 21 | autoComplete: "off", | |
| 22 | spellCheck: false, | |
| 23 | placeholder: configured | |
| 24 | ? "Stored secret, enter a new value to replace" | |
| 25 | : (placeholder ?? "Not set"), | |
| 26 | }; | |
| 27 | if (onCommit) { | |
| 28 | return ( | |
| 29 | <DraftInput | |
| 30 | {...shared} | |
| 31 | onCommit={(next) => { | |
| 32 | if (configured && next === "") return; | |
| 33 | onCommit(next); | |
| 34 | }} | |
| 35 | /> | |
| 36 | ); | |
| 37 | } | |
| 38 | return <Input {...shared} />; | |
| 39 | } |
packages/contracts/src/settings.ts+24-7| ... | ... | @@ -551,15 +551,19 @@ export interface ProviderSettingsFormAnnotation { |
| 551 | 551 | readonly placeholder?: string | undefined; |
| 552 | 552 | readonly hidden?: boolean | undefined; |
| 553 | 553 | readonly clearWhenEmpty?: "omit" | "persist" | undefined; |
| 554 | /** | |
| 555 | * The value is a credential: the server keeps it in its secret store and clients only learn | |
| 556 | * whether one is saved. Source control host settings honor it; provider settings do not yet. | |
| 557 | */ | |
| 558 | readonly secret?: boolean | undefined; | |
| 559 | 554 | /** Choices for a `select` control. The first entry is the default. */ |
| 560 | 555 | readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined; |
| 561 | 556 | } |
| 562 | 557 | |
| 558 | /** Secret fields declared by a settings schema, shared by persistence and client redaction. */ | |
| 559 | export function secretSettingsFields(schema: { | |
| 560 | readonly fields: Readonly<Record<string, Schema.Top>>; | |
| 561 | }): ReadonlyArray<string> { | |
| 562 | return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => { | |
| 563 | return isSecretSetting(fieldSchema) ? [field] : []; | |
| 564 | }); | |
| 565 | } | |
| 566 | ||
| 563 | 567 | export interface ProviderSettingsFormSchemaAnnotation { |
| 564 | 568 | readonly order?: readonly string[] | undefined; |
| 565 | 569 | } |
| ... | ... | @@ -567,12 +571,24 @@ export interface ProviderSettingsFormSchemaAnnotation { |
| 567 | 571 | declare module "effect/Schema" { |
| 568 | 572 | namespace Annotations { |
| 569 | 573 | interface Annotations { |
| 574 | /** Store this credential separately and expose only whether it is configured to clients. */ | |
| 575 | readonly settingsSecret?: boolean | undefined; | |
| 570 | 576 | readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined; |
| 571 | 577 | readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined; |
| 572 | 578 | } |
| 573 | 579 | } |
| 574 | 580 | } |
| 575 | 581 | |
| 582 | /** Mark a string or a record of strings as a credential in its settings schema. */ | |
| 583 | export function secretSetting<S extends Schema.Top>(schema: S): S["Rebuild"] { | |
| 584 | return schema.annotateKey({ settingsSecret: true }); | |
| 585 | } | |
| 586 | ||
| 587 | export function isSecretSetting(schema: Schema.Top): boolean { | |
| 588 | const annotations = Schema.resolveAnnotationsKey(schema) ?? Schema.resolveAnnotations(schema); | |
| 589 | return annotations?.settingsSecret === true; | |
| 590 | } | |
| 591 | ||
| 576 | 592 | export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract< |
| 577 | 593 | keyof Fields, |
| 578 | 594 | string |
| ... | ... | @@ -749,10 +765,11 @@ export const AntigravitySettings = makeProviderSettingsSchema( |
| 749 | 765 | }), |
| 750 | 766 | ), |
| 751 | 767 | apiKey: TrimmedString.pipe( |
| 768 | secretSetting, | |
| 752 | 769 | Schema.withDecodingDefault(Effect.succeed("")), |
| 753 | 770 | Schema.annotateKey({ |
| 754 | 771 | title: "API key", |
| 755 | description: "Gemini or Vertex AI express key. Stored in plain text.", | |
| 772 | description: "Gemini or Vertex AI express key. Stored separately on the host.", | |
| 756 | 773 | providerSettingsForm: { |
| 757 | 774 | control: "password", |
| 758 | 775 | placeholder: "Optional", |
| ... | ... | @@ -804,7 +821,7 @@ export const UsageLimitSourceConfig = Schema.Struct({ |
| 804 | 821 | kind: Schema.Literal("cliproxy"), |
| 805 | 822 | label: Schema.optional(TrimmedNonEmptyString), |
| 806 | 823 | url: TrimmedNonEmptyString, |
| 807 | managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 824 | managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), | |
| 808 | 825 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 809 | 826 | }); |
| 810 | 827 | export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; |
packages/source-control-bitbucket/src/client/definition.ts+4-2| ... | ... | @@ -39,7 +39,8 @@ export const settings = makeProviderSettingsSchema( |
| 39 | 39 | title: "Access token", |
| 40 | 40 | description: |
| 41 | 41 | "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", |
| 42 | providerSettingsForm: { control: "password", secret: true }, | |
| 42 | settingsSecret: true, | |
| 43 | providerSettingsForm: { control: "password" }, | |
| 43 | 44 | }), |
| 44 | 45 | email: text({ |
| 45 | 46 | title: "Atlassian account email", |
| ... | ... | @@ -50,7 +51,8 @@ export const settings = makeProviderSettingsSchema( |
| 50 | 51 | title: "API token", |
| 51 | 52 | description: |
| 52 | 53 | "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", |
| 53 | providerSettingsForm: { control: "password", secret: true }, | |
| 54 | settingsSecret: true, | |
| 55 | providerSettingsForm: { control: "password" }, | |
| 54 | 56 | }), |
| 55 | 57 | }, |
| 56 | 58 | { order: ["accessToken", "email", "apiToken"] }, |
packages/source-control-core/src/client/definition.ts+1-15| ... | ... | @@ -144,7 +144,7 @@ export interface SourceControlClientDefinition { |
| 144 | 144 | /** |
| 145 | 145 | * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered |
| 146 | 146 | * as a form in Source Control settings and read by the host's server package. Fields carry |
| 147 | * `providerSettingsForm` annotations, as agent provider settings do; one marked `secret` is | |
| 147 | * `providerSettingsForm` annotations, as agent provider settings do; a `secretSetting` is | |
| 148 | 148 | * kept in the server's secret store. Omitted for a host with nothing to configure. |
| 149 | 149 | */ |
| 150 | 150 | readonly settings?: SourceControlHostSettingsSchema; |
| ... | ... | @@ -322,17 +322,3 @@ export function readSourceControlHostSettings<S extends SourceControlHostSetting |
| 322 | 322 | if (fallback._tag === "Some") return fallback.value; |
| 323 | 323 | throw new Error("A source control host settings schema must decode an empty object."); |
| 324 | 324 | } |
| 325 | ||
| 326 | /** | |
| 327 | * The settings fields a host keeps in the server's secret store: those annotated | |
| 328 | * `providerSettingsForm.secret`. A secret field holds a string, or a string per server host. | |
| 329 | */ | |
| 330 | export function secretSourceControlHostSettingsFields( | |
| 331 | schema: SourceControlHostSettingsSchema, | |
| 332 | ): ReadonlyArray<string> { | |
| 333 | return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => { | |
| 334 | const annotations = | |
| 335 | Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema); | |
| 336 | return annotations?.providerSettingsForm?.secret ? [field] : []; | |
| 337 | }); | |
| 338 | } |
packages/source-control-gitcafe/src/client/definition.ts+3-1| ... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 | */ |
| 6 | 6 | import { |
| 7 | 7 | makeProviderSettingsSchema, |
| 8 | secretSetting, | |
| 8 | 9 | SourceControlProviderKind, |
| 9 | 10 | TrimmedString, |
| 10 | 11 | } from "@t3tools/contracts"; |
| ... | ... | @@ -25,12 +26,13 @@ const KIND = SourceControlProviderKind.make("gitcafe"); |
| 25 | 26 | /** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */ |
| 26 | 27 | export const settings = makeProviderSettingsSchema({ |
| 27 | 28 | token: TrimmedString.pipe( |
| 29 | secretSetting, | |
| 28 | 30 | Schema.withDecodingDefault(Effect.succeed("")), |
| 29 | 31 | Schema.annotateKey({ |
| 30 | 32 | title: "Token", |
| 31 | 33 | description: |
| 32 | 34 | "A git.cafe token, used before CAFE_TOKEN and the cafe login, so GitCafe works without the CLI signed in.", |
| 33 | providerSettingsForm: { control: "password", secret: true }, | |
| 35 | providerSettingsForm: { control: "password" }, | |
| 34 | 36 | }), |
| 35 | 37 | ), |
| 36 | 38 | }); |
packages/source-control-github/src/client/definition.ts+3-1| ... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 | import { |
| 7 | 7 | GitHubHost, |
| 8 | 8 | makeProviderSettingsSchema, |
| 9 | secretSetting, | |
| 9 | 10 | pullRequestHostOf, |
| 10 | 11 | SourceControlProviderKind, |
| 11 | 12 | TrimmedNonEmptyString, |
| ... | ... | @@ -45,7 +46,8 @@ export const settings = makeProviderSettingsSchema({ |
| 45 | 46 | ), |
| 46 | 47 | tokens: Schema.Record(GitHubHost, TrimmedString).pipe( |
| 47 | 48 | Schema.withDecodingDefault(Effect.succeed({})), |
| 48 | Schema.annotateKey({ providerSettingsForm: { hidden: true, secret: true } }), | |
| 49 | secretSetting, | |
| 50 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 49 | 51 | ), |
| 50 | 52 | }); |
| 51 | 53 | export type GitHubSettings = typeof settings.Type; |