@ouro.bot/cli 0.1.0-alpha.970 → 0.1.0-alpha.972

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -265,7 +265,7 @@ function usage() {
265
265
  " ouro vault recover [--agent <name>] --from <json> [--from <json>] [--email <email>] [--server <url>] [--store <store>]",
266
266
  " ouro vault unlock [--agent <name>] [--store auto|macos-keychain|windows-dpapi|linux-secret-service|plaintext-file]",
267
267
  " ouro vault status [--agent <name>] [--store auto|macos-keychain|windows-dpapi|linux-secret-service|plaintext-file]",
268
- " ouro vault config set [--agent <name>] --key <path> [--value <value>] [--scope agent|machine]",
268
+ " ouro vault config set [--agent <name>] --key <path> [--value <value> | --value-file <path>] [--scope agent|machine]",
269
269
  " ouro vault config status [--agent <name>] [--scope agent|machine|all]",
270
270
  " ouro vault item set [--agent <name>] --item <path> --secret-field <name> [--public-field <key=value>] [--note <text>]",
271
271
  " ouro vault item status [--agent <name>] --item <path>",
@@ -1173,9 +1173,15 @@ function parseVaultConfigCommand(args) {
1173
1173
  const { agent, rest } = extractAgentFlag(args.slice(1));
1174
1174
  let key;
1175
1175
  let value;
1176
+ let valueFile;
1176
1177
  let scope;
1177
1178
  for (let i = 0; i < rest.length; i += 1) {
1178
1179
  const token = rest[i];
1180
+ if (token === "--value-file") {
1181
+ valueFile = rest[i + 1] ?? "";
1182
+ i += 1;
1183
+ continue;
1184
+ }
1179
1185
  if (token === "--key") {
1180
1186
  key = rest[i + 1];
1181
1187
  i += 1;
@@ -1201,7 +1207,7 @@ function parseVaultConfigCommand(args) {
1201
1207
  throw new Error("Usage: ouro vault config set [--agent <name>] --key <path> [--value <value>] OR ouro vault config status [--agent <name>]");
1202
1208
  }
1203
1209
  if (sub === "status") {
1204
- if (key || value) {
1210
+ if (key || value || valueFile) {
1205
1211
  throw new Error("Usage: ouro vault config status [--agent <name>]");
1206
1212
  }
1207
1213
  return { kind: "vault.config.status", ...(agent ? { agent } : {}), ...(scope ? { scope } : {}) };
@@ -1209,9 +1215,13 @@ function parseVaultConfigCommand(args) {
1209
1215
  if (scope === "all")
1210
1216
  throw new Error("vault config --scope all is only valid for status");
1211
1217
  if (!key) {
1212
- throw new Error("Usage: ouro vault config set [--agent <name>] --key <path> [--value <value>]");
1218
+ throw new Error("Usage: ouro vault config set [--agent <name>] --key <path> [--value <value> | --value-file <path>]");
1213
1219
  }
1214
- return { kind: "vault.config.set", ...(agent ? { agent } : {}), key, ...(value !== undefined ? { value } : {}), ...(scope ? { scope } : {}) };
1220
+ if (value !== undefined && valueFile !== undefined)
1221
+ throw new Error("vault config set takes --value or --value-file, not both");
1222
+ if (valueFile !== undefined && !valueFile)
1223
+ throw new Error("vault config set --value-file needs a path (use /dev/stdin to pipe the value)");
1224
+ return { kind: "vault.config.set", ...(agent ? { agent } : {}), key, ...(value !== undefined ? { value } : {}), ...(valueFile !== undefined ? { valueFile } : {}), ...(scope ? { scope } : {}) };
1215
1225
  }
1216
1226
  function normalizeConnectTarget(value) {
1217
1227
  if (!value)
@@ -81,9 +81,12 @@ function validate(value) {
81
81
  || typeof epoch.epochId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(epoch.epochId)
82
82
  || ![epoch.botId, epoch.ownerUserId, epoch.ownerChatId].every((id) => typeof id === "string" && /^[1-9][0-9]*$/u.test(id)) || epoch.ownerUserId !== epoch.ownerChatId
83
83
  || !Number.isSafeInteger(epoch.predecessorCursor) || epoch.predecessorCursor < 0
84
- || ![epoch.packageDigest, epoch.tokenDigest, epoch.previousTokenDigest, epoch.publicKeyDigest].every((value) => typeof value === "string" && DIGEST.test(value))
84
+ || ![epoch.packageDigest, epoch.tokenDigest, epoch.publicKeyDigest].every((value) => typeof value === "string" && DIGEST.test(value))
85
+ || (epoch.previousTokenDigest === null
86
+ ? epoch.revokedTokenStatus !== null || epoch.predecessorCursor !== 0
87
+ : typeof epoch.previousTokenDigest !== "string" || !DIGEST.test(epoch.previousTokenDigest) || epoch.revokedTokenStatus !== 401)
85
88
  || epoch.tokenDigest === epoch.previousTokenDigest || typeof epoch.tokenPath !== "string" || !path.isAbsolute(epoch.tokenPath)
86
- || typeof epoch.publicKeyPem !== "string" || epoch.revokedTokenStatus !== 401
89
+ || typeof epoch.publicKeyPem !== "string"
87
90
  || typeof epoch.createdAt !== "string" || !Number.isFinite(Date.parse(epoch.createdAt)) || new Date(epoch.createdAt).toISOString() !== epoch.createdAt
88
91
  || (epoch.state === "prepared" ? epoch.terminalCursor !== null : !Number.isSafeInteger(epoch.terminalCursor) || epoch.terminalCursor < epoch.predecessorCursor))
89
92
  throw new Error("Sanctuary authority epoch is invalid");
@@ -154,19 +157,26 @@ async function prepareSanctuaryAuthorityEpoch(input, options) {
154
157
  || !Number.isSafeInteger(input.predecessorCursor) || input.predecessorCursor < 0 || !DIGEST.test(input.packageDigest))
155
158
  throw new Error("Sanctuary authority epoch input is invalid");
156
159
  const token = privateBytes(input.tokenPath, options).toString("utf8").trim();
157
- const previousToken = privateBytes(input.previousTokenPath, options).toString("utf8").trim();
158
- if (!TOKEN.test(token) || !TOKEN.test(previousToken) || token.split(":")[0] !== input.botId || previousToken.split(":")[0] !== input.botId || token === previousToken)
160
+ // A fresh install has no predecessor token to prove revoked, and therefore no cursor to carry.
161
+ const previousToken = input.previousTokenPath === null ? null : privateBytes(input.previousTokenPath, options).toString("utf8").trim();
162
+ if (previousToken === null && input.predecessorCursor !== 0)
163
+ throw new Error("Sanctuary authority epoch input is invalid");
164
+ if (!TOKEN.test(token) || token.split(":")[0] !== input.botId
165
+ || (previousToken !== null && (!TOKEN.test(previousToken) || previousToken.split(":")[0] !== input.botId || token === previousToken)))
159
166
  throw new Error("Sanctuary authority requires a fresh same-bot token");
160
- const previous = await options.probe(previousToken);
161
- if (previous.status !== 401 || previous.botId !== null)
162
- throw new Error("Sanctuary previous Telegram token is not proven revoked");
167
+ if (previousToken !== null) {
168
+ const previous = await options.probe(previousToken);
169
+ if (previous.status !== 401 || previous.botId !== null)
170
+ throw new Error("Sanctuary previous Telegram token is not proven revoked");
171
+ }
163
172
  const current = await options.probe(token);
164
173
  if (current.status !== 200 || current.botId !== input.botId)
165
174
  throw new Error("Sanctuary new Telegram token identity is invalid");
166
175
  const epochPath = path.join(input.root, "epoch.json");
167
176
  const keyPath = path.join(input.root, "issuer.pem");
168
177
  const intentPath = path.join(input.root, "issuer-intent.json");
169
- const inputDigest = sha(JSON.stringify({ ...input, tokenDigest: sha(token), previousTokenDigest: sha(previousToken) }));
178
+ const previousTokenDigest = previousToken === null ? null : sha(previousToken);
179
+ const inputDigest = sha(JSON.stringify({ ...input, tokenDigest: sha(token), previousTokenDigest }));
170
180
  return (0, session_transaction_1.withImmediateSessionTurnLease)(epochPath, (lease) => {
171
181
  const transaction = (0, session_transaction_1.readSessionTransaction)(epochPath, lease);
172
182
  let existing = false;
@@ -186,7 +196,7 @@ async function prepareSanctuaryAuthorityEpoch(input, options) {
186
196
  if (epoch[key] !== input[key])
187
197
  throw new Error("Sanctuary authority epoch input changed");
188
198
  }
189
- if (epoch.previousTokenDigest !== sha(previousToken))
199
+ if (epoch.previousTokenDigest !== previousTokenDigest)
190
200
  throw new Error("Sanctuary authority predecessor token changed");
191
201
  if (fs.existsSync(intentPath)) {
192
202
  const intent = JSON.parse(privateBytes(intentPath, options).toString("utf8"));
@@ -218,8 +228,8 @@ async function prepareSanctuaryAuthorityEpoch(input, options) {
218
228
  }
219
229
  const epoch = validate({
220
230
  schemaVersion: 1, state: "prepared", epochId: input.epochId, botId: input.botId, ownerUserId: input.ownerUserId, ownerChatId: input.ownerChatId,
221
- predecessorCursor: input.predecessorCursor, packageDigest: input.packageDigest, tokenPath: input.tokenPath, tokenDigest: sha(token), previousTokenDigest: sha(previousToken),
222
- ...issuer(input.root, options), revokedTokenStatus: 401, createdAt: intent.createdAt, terminalCursor: null,
231
+ predecessorCursor: input.predecessorCursor, packageDigest: input.packageDigest, tokenPath: input.tokenPath, tokenDigest: sha(token), previousTokenDigest,
232
+ ...issuer(input.root, options), revokedTokenStatus: previousTokenDigest === null ? null : 401, createdAt: intent.createdAt, terminalCursor: null,
223
233
  });
224
234
  (0, session_transaction_1.writeSessionTransaction)(epochPath, epoch, { lease, expectedRevision: transaction.revision });
225
235
  fs.unlinkSync(intentPath);
@@ -103,7 +103,10 @@ const EPOCH_RECORDS = ["migration.json", "stage.json", "configured.json"];
103
103
  const digest = (value) => `sha256:${(0, node_crypto_1.createHash)("sha256").update(value).digest("hex")}`;
104
104
  function validateRequest(value) {
105
105
  const request = value;
106
- if (!request || typeof request !== "object" || Object.keys(request).sort().join(",") !== "botId,epochId,nodeDigest,ownerChatId,ownerUserId,packageDigest,prlimitDigest,schemaVersion,setsidDigest,shellDigest"
106
+ const keys = "botId,epochId,nodeDigest,ownerChatId,ownerUserId,packageDigest,prlimitDigest,schemaVersion,setsidDigest,shellDigest";
107
+ if (!request || typeof request !== "object"
108
+ || Object.keys(request).filter((key) => key !== "predecessorContract").sort().join(",") !== keys
109
+ || (Object.hasOwn(request, "predecessorContract") && request.predecessorContract !== "none")
107
110
  || request.schemaVersion !== 1 || !/^[A-Za-z0-9_-]{1,128}$/u.test(request.epochId)
108
111
  || ![request.botId, request.ownerUserId, request.ownerChatId].every((id) => typeof id === "string" && /^[1-9][0-9]*$/u.test(id))
109
112
  || request.ownerUserId !== request.ownerChatId
@@ -128,17 +131,20 @@ class SanctuaryAuthorityRootLifecycle {
128
131
  this.#socketGid = options.socketGroupId ?? 10001;
129
132
  if (process.getuid() !== this.#uid || process.getgid() !== this.#gid)
130
133
  throw new Error("Sanctuary root lifecycle requires root");
131
- if (!DIGEST.test(transaction.targetImageId) || !DIGEST.test(transaction.rollbackImageId) || transaction.targetImageId === transaction.rollbackImageId)
132
- throw new Error("Sanctuary root lifecycle image identity is invalid");
133
- this.#transaction = { targetImageId: transaction.targetImageId, rollbackImageId: transaction.rollbackImageId };
134
134
  this.#directory(ROOT);
135
135
  this.#request = validateRequest(JSON.parse(this.#private(`${ROOT}/request.json`)));
136
+ const rollbackValid = transaction.rollbackImageId === null ? this.#fresh() : DIGEST.test(transaction.rollbackImageId);
137
+ if (!DIGEST.test(transaction.targetImageId) || !rollbackValid || transaction.targetImageId === transaction.rollbackImageId)
138
+ throw new Error("Sanctuary root lifecycle image identity is invalid");
139
+ this.#transaction = { targetImageId: transaction.targetImageId, rollbackImageId: transaction.rollbackImageId };
136
140
  (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_root_lifecycle_loaded", message: "Sanctuary root lifecycle identity loaded", meta: { epochId: this.#request.epochId, targetImageId: transaction.targetImageId } });
137
141
  }
138
142
  plan() {
139
143
  const { epochId, botId, ownerUserId, ownerChatId, packageDigest } = this.#request;
140
- return { schemaVersion: 1, epochId, botId, ownerUserId, ownerChatId, packageDigest, publicKeyDigest: null, predecessorContract: "canonical-pre-gateway", targetContract: "canonical-gateway" };
144
+ return { schemaVersion: 1, epochId, botId, ownerUserId, ownerChatId, packageDigest, publicKeyDigest: null, predecessorContract: this.#fresh() ? "none" : "canonical-pre-gateway", targetContract: "canonical-gateway" };
141
145
  }
146
+ /** Whether this is a fresh install, which hands over nothing from a predecessor. */
147
+ #fresh() { return this.#request.predecessorContract === "none"; }
142
148
  async boot() {
143
149
  if (!fs.existsSync(this.#p(`${ROOT}/activation.json`)))
144
150
  return false;
@@ -212,9 +218,15 @@ class SanctuaryAuthorityRootLifecycle {
212
218
  "rollback:reconcile-executions": { read: () => this.#retiredProcess(), apply: () => this.#retireProcess() },
213
219
  "rollback:stop-gateway": { read: () => this.#retiredProcess() && this.#pid() === null, apply: () => this.#stopGateway() },
214
220
  "rollback:end-epoch": { read: () => this.#epoch().state === "retired", apply: () => this.#endEpoch() },
215
- "rollback:restore-token-cursor": { read: () => this.#restoredToken(), apply: () => this.#restoreToken() },
216
- "rollback:restore-resident": { read: () => this.#rollback().State.Running, apply: () => this.#restoreResident() },
217
- "rollback:verify-rollback": { read: () => this.#rollbackVerified(), apply: () => this.#verifyRollback() },
221
+ "rollback:restore-token-cursor": this.#fresh()
222
+ ? { read: () => this.#epoch().state === "retired", apply: () => this.#requireStopped() }
223
+ : { read: () => this.#restoredToken(), apply: () => this.#restoreToken() },
224
+ "rollback:restore-resident": this.#fresh()
225
+ ? { read: () => this.#residentAbsent(), apply: () => this.#removeResident() }
226
+ : { read: () => this.#rollback().State.Running, apply: () => this.#restoreResident() },
227
+ "rollback:verify-rollback": this.#fresh()
228
+ ? { read: () => fs.existsSync(this.#p(`${this.#epochRoot()}/rollback.json`)) && this.#freshRollbackClean(), apply: () => this.#verifyFreshRollback() }
229
+ : { read: () => this.#rollbackVerified(), apply: () => this.#verifyRollback() },
218
230
  };
219
231
  const effect = effects[step];
220
232
  if (!effect)
@@ -248,6 +260,13 @@ class SanctuaryAuthorityRootLifecycle {
248
260
  this.#migration();
249
261
  return;
250
262
  }
263
+ if (this.#fresh()) {
264
+ // A fresh install starts from nothing: an installed authority here means this is not one.
265
+ if (fs.existsSync(this.#p(`${ROOT}/active.json`)) || fs.existsSync(this.#p(`${ROOT}/activation.json`)))
266
+ throw new Error("Sanctuary fresh install found an installed authority");
267
+ this.#write(`${this.#epochRoot()}/migration.json`, JSON.stringify({ ...this.plan(), ...this.#transaction, predecessorCursor: 0 }));
268
+ return;
269
+ }
251
270
  if (fs.existsSync(this.#p(`${ROOT}/active.json`))) {
252
271
  const previous = JSON.parse(this.#private(`${ROOT}/active.json`));
253
272
  const epoch = (0, sanctuary_authority_epoch_1.readSanctuaryAuthorityEpoch)(previous.epochRoot, { expectedUid: this.#uid, expectedGid: this.#gid });
@@ -430,7 +449,7 @@ class SanctuaryAuthorityRootLifecycle {
430
449
  this.#write(`${this.#epochRoot()}/current-token`, token);
431
450
  await (0, sanctuary_authority_epoch_1.prepareSanctuaryAuthorityEpoch)({
432
451
  root: this.#p(this.#epochRoot()), epochId: this.#request.epochId, tokenPath: this.#p(`${this.#epochRoot()}/current-token`),
433
- previousTokenPath: this.#p(`${this.#epochRoot()}/previous-token`), botId: this.#request.botId,
452
+ previousTokenPath: this.#fresh() ? null : this.#p(`${this.#epochRoot()}/previous-token`), botId: this.#request.botId,
434
453
  ownerUserId: this.#request.ownerUserId, ownerChatId: this.#request.ownerChatId,
435
454
  predecessorCursor: this.#migration().predecessorCursor, packageDigest: this.#request.packageDigest,
436
455
  }, {
@@ -781,6 +800,29 @@ class SanctuaryAuthorityRootLifecycle {
781
800
  this.#docker(["start", "ouro-butler"]);
782
801
  await this.#wait(() => this.#rollback().State.Health?.Status === "healthy" && this.#rollback().State.Running);
783
802
  }
803
+ // A fresh install rolls back to nothing: its token stays in root custody for a retry,
804
+ // and the resident it created is removed rather than restored.
805
+ #residentAbsent() {
806
+ return !this.#containers().some((container) => container.Name === "/ouro-butler");
807
+ }
808
+ #removeResident() {
809
+ this.#assertNoForeignPoller();
810
+ if (this.#epoch().state !== "retired" || this.#pid() !== null)
811
+ throw new Error("Sanctuary epoch must retire before the fresh resident is removed");
812
+ const resident = this.#containers().find((container) => container.Name === "/ouro-butler");
813
+ if (resident && resident.Image !== this.#transaction.targetImageId)
814
+ throw new Error("Sanctuary resident image changed");
815
+ if (resident)
816
+ this.#docker(["rm", "--force", "ouro-butler"]);
817
+ }
818
+ #freshRollbackClean() {
819
+ return this.#epoch().state === "retired" && this.#pid() === null && this.#residentAbsent();
820
+ }
821
+ #verifyFreshRollback() {
822
+ if (!this.#freshRollbackClean())
823
+ throw new Error("Sanctuary rollback readback failed");
824
+ this.#write(`${this.#epochRoot()}/rollback.json`, JSON.stringify({ epochId: this.#request.epochId, state: "retired" }));
825
+ }
784
826
  #rollbackVerified() {
785
827
  return fs.existsSync(this.#p(`${this.#epochRoot()}/rollback.json`)) && this.#restoredToken() && this.#pid() === null && this.#rollback().State.Running && this.#rollback().State.Health?.Status === "healthy";
786
828
  }
@@ -889,7 +931,7 @@ class SanctuaryAuthorityRootLifecycle {
889
931
  || !journal.completed.every((step) => exports.SANCTUARY_UPGRADE_STEPS.includes(step))
890
932
  || (journal.rollingBack !== undefined && journal.rollingBack !== true)
891
933
  || !sides.every((side) => side && DIGEST.test(side.imageId) && DIGEST.test(side.packageDigest) && IMAGE_REFERENCE.test(side.imageReference))
892
- || !DIGEST.test(journal.from.rollbackImageId))
934
+ || !(journal.from.rollbackImageId === null ? this.#fresh() : DIGEST.test(journal.from.rollbackImageId)))
893
935
  throw new Error("Sanctuary upgrade journal is invalid");
894
936
  return journal;
895
937
  }
@@ -901,7 +943,7 @@ class SanctuaryAuthorityRootLifecycle {
901
943
  if (this.#docker(["image", "inspect", "--format", "{{.Id}}", input.imageReference]).trim() !== input.targetImageId)
902
944
  throw new Error("Sanctuary upgrade image identity does not match its reference");
903
945
  const request = validateRequest(JSON.parse(this.#private(INCOMING_REQUEST)));
904
- if (["epochId", "botId", "ownerUserId", "ownerChatId"].some((key) => request[key] !== this.#request[key]))
946
+ if (["epochId", "botId", "ownerUserId", "ownerChatId", "predecessorContract"].some((key) => request[key] !== this.#request[key]))
905
947
  throw new Error("Sanctuary upgrade would change the authority identity");
906
948
  if (request.packageDigest === this.#request.packageDigest)
907
949
  throw new Error("Sanctuary upgrade package is already installed");