@joe-broadhead/opencode-gws 0.0.2 → 0.0.4

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.
package/dist/index.js CHANGED
@@ -5320,8 +5320,8 @@ function rewriteKeyNames(ctx) {
5320
5320
  bySchema.set(entry.schema, entry);
5321
5321
  }
5322
5322
  const rewrites = /* @__PURE__ */ new Map();
5323
- for (const record2 of pendingRecords.get(ctx) ?? []) {
5324
- const seen = ctx.seen.get(record2);
5323
+ for (const record3 of pendingRecords.get(ctx) ?? []) {
5324
+ const seen = ctx.seen.get(record3);
5325
5325
  const names = (seen?.def ?? seen?.schema)?.propertyNames;
5326
5326
  if (!names || names === true || rewrites.has(names))
5327
5327
  continue;
@@ -6575,9 +6575,17 @@ var ErrorCode = {
6575
6575
  meaning: "Plugin policy refused the call: the category, the service, recipients, public sharing, the admin gate, or a path outside the allowed roots. A deny is final.",
6576
6576
  next: "Do not work around it. If the message says to use gws_plan, plan the change; otherwise tell the user, who can change the plugin policy."
6577
6577
  },
6578
+ RECIPIENTS_UNKNOWN: {
6579
+ meaning: "A policy rule limits recipients, and the plugin could not tell who this send or share reaches: a raw message without readable To, Cc or Bcc headers, a draft, message or event it could not read, a draft that names no recipient, or a method whose recipients it does not know. Nothing ran.",
6580
+ next: "Name the recipients in the request (to, cc and bcc, or the To, Cc and Bcc headers of raw), or tell the user the policy rule refuses this method."
6581
+ },
6582
+ ACCOUNT_DOMAIN_UNKNOWN: {
6583
+ meaning: "A policy rule sets allowExternalRecipients to false, and the account's own email domain is unknown: accounts.<alias>.domains is unset and the signed-in address could not be read. Nothing ran.",
6584
+ next: "Ask the user to set accounts.<alias>.domains to the account's email domains, or to enable Gmail, Drive or Calendar for the account so the plugin can read its address."
6585
+ },
6578
6586
  // ── Plans ─────────────────────────────────────────────────────────────
6579
6587
  PLAN_NOT_FOUND: {
6580
- meaning: "No plan has this planId, or it was removed.",
6588
+ meaning: "No plan has this planId: it never existed, or its time limit passed and it was removed.",
6581
6589
  next: "Create a plan with gws_plan, then commit its planId."
6582
6590
  },
6583
6591
  PLAN_ALREADY_USED: {
@@ -6599,15 +6607,15 @@ var ErrorCode = {
6599
6607
  // ── Scripts (the tools RPC) ───────────────────────────────────────────
6600
6608
  SESSION_UNKNOWN: {
6601
6609
  meaning: "A script named a session that does not exist. A script runs as a session, under that session's agent and its permission rules.",
6602
- next: "Pass the id of an existing session, or create one for the agent the script should run as."
6610
+ next: 'Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.'
6603
6611
  },
6604
6612
  SESSION_AGENT_REQUIRED: {
6605
6613
  meaning: "A script named a session that names no agent. A script runs as its session's agent, so its permissions and plans never depend on the default agent.",
6606
- next: `Create the script's session with an agent, for example POST /api/session with {"agent":"build"}.`
6614
+ next: 'Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.'
6607
6615
  },
6608
6616
  PERMISSION_DENIED: {
6609
6617
  meaning: "The permission rules of the session's agent deny the call, or ask for an approval that a script cannot give, so the call did not run.",
6610
- next: "Run the script as a session whose agent allows the action, or add an allow rule for that action to the agent."
6618
+ next: "Add an allow rule for the tool to the session's agent or to the session, or use a session whose agent allows it."
6611
6619
  },
6612
6620
  // ── Sign-in ───────────────────────────────────────────────────────────
6613
6621
  AUTH_REQUIRED: {
@@ -6619,7 +6627,7 @@ var ErrorCode = {
6619
6627
  next: "Ask the user to sign in again with the access the message names, or to add the scope to the account's scopes, then retry. For a method only an app's credentials can run, use another method."
6620
6628
  },
6621
6629
  ADMIN_PRIVILEGE_REQUIRED: {
6622
- meaning: "The sign-in is valid, but Google refused the call because the account lacks a Workspace admin privilege the API needs, such as Reports for admin-reports. Signing in again as the same account does not help, and the sign-in is not marked as needing renewal.",
6630
+ meaning: "The sign-in is valid, but an Admin SDK service (admin-reports) refused the call because the account lacks a Workspace admin privilege the API needs, such as Reports. Signing in again as the same account does not help, and the sign-in is not marked as needing renewal. Another service's refusal is never this code.",
6623
6631
  next: "Ask the user to sign in with a Workspace admin account that has the needed privilege (Reports for admin-reports), or to remove the service from enabledServices."
6624
6632
  },
6625
6633
  AUTH_CLIENT_REQUIRED: {
@@ -6706,8 +6714,8 @@ var ErrorCode = {
6706
6714
  },
6707
6715
  // ── Setup: these stop the plugin from loading ────────────────────────
6708
6716
  HOST_UNTESTED: {
6709
- meaning: "The OpenCode version is outside the tested range in package.json opencode.host.",
6710
- next: "Use a tested OpenCode release, or set allowUntestedHost to true to run anyway with a status warning."
6717
+ meaning: "The OpenCode version is outside the supported range in package.json opencode.host (>=2.2.3 <2.3.0).",
6718
+ next: "Use OpenCode 2.2.3, 2.2.4 or 2.2.5 (tested) or another release in the range, or set allowUntestedHost to true to run anyway; status then shows the host as untested."
6711
6719
  },
6712
6720
  OPTIONS_INVALID: {
6713
6721
  meaning: "The plugin options are not valid: an unknown key, a wrong type, an unknown service, or access or scopes that name something the account does not enable.",
@@ -6772,8 +6780,8 @@ var GwsError = class _GwsError extends Error {
6772
6780
  return stated ? new _GwsError(code2, stated[1], { next: stated[2] }) : new _GwsError(code2, match[2]);
6773
6781
  }
6774
6782
  };
6775
- function sentence(text3) {
6776
- const trimmed = text3.trim();
6783
+ function sentence(text4) {
6784
+ const trimmed = text4.trim();
6777
6785
  const capital = trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
6778
6786
  return /[.!?]$/.test(capital) ? capital : `${capital}.`;
6779
6787
  }
@@ -6973,22 +6981,29 @@ var replyBase = {
6973
6981
  messageId: string2().min(1),
6974
6982
  body: string2().min(1).max(1e5),
6975
6983
  from: string2().email().optional(),
6984
+ to: union([string2(), array(string2())]).optional().describe("Extra To recipients, besides the ones the reply goes to"),
6976
6985
  cc: union([string2(), array(string2())]).optional(),
6977
6986
  bcc: union([string2(), array(string2())]).optional(),
6978
6987
  html: boolean2().optional(),
6979
6988
  draft: boolean2().optional(),
6980
6989
  attachments: array(absolutePath).max(20).optional()
6981
6990
  };
6982
- function replyAdapter(id, name) {
6991
+ var replyAllBase = {
6992
+ ...replyBase,
6993
+ remove: union([string2(), array(string2())]).optional().describe("Addresses to leave out of the reply-all, the sender's included")
6994
+ };
6995
+ function replyAdapter(id, name, all) {
6983
6996
  return {
6984
6997
  id,
6985
6998
  service: "gmail",
6986
6999
  category: "send",
6987
- inputSchema: strictObject(replyBase),
7000
+ inputSchema: strictObject(all ? replyAllBase : replyBase),
6988
7001
  build(input, request) {
6989
7002
  const parsed = this.inputSchema.parse(input);
6990
7003
  const argv = ["gmail", name, "--message-id", parsed.messageId, "--body", parsed.body];
6991
7004
  if (parsed.from) argv.push("--from", parsed.from);
7005
+ if (parsed.to) argv.push("--to", emails(parsed.to).join(","));
7006
+ if (parsed.remove) argv.push("--remove", emails(parsed.remove).join(","));
6992
7007
  if (parsed.cc) argv.push("--cc", emails(parsed.cc).join(","));
6993
7008
  if (parsed.bcc) argv.push("--bcc", emails(parsed.bcc).join(","));
6994
7009
  if (parsed.html) argv.push("--html");
@@ -7001,9 +7016,9 @@ function replyAdapter(id, name) {
7001
7016
  files,
7002
7017
  preview: {
7003
7018
  method: `gmail ${name}`,
7004
- params: { messageId: parsed.messageId, cc: emails(parsed.cc), bcc: emails(parsed.bcc) },
7019
+ params: { messageId: parsed.messageId, to: emails(parsed.to), cc: emails(parsed.cc), bcc: emails(parsed.bcc), ...all ? { remove: emails(parsed.remove) } : {}, draft: parsed.draft ?? false },
7005
7020
  bodySummary: bytes(parsed.body),
7006
- recipients: [...emails(parsed.cc), ...emails(parsed.bcc)],
7021
+ recipients: [...emails(parsed.to), ...emails(parsed.cc), ...emails(parsed.bcc)],
7007
7022
  uploads: parsed.attachments,
7008
7023
  account: request.account,
7009
7024
  category: "send"
@@ -7012,30 +7027,45 @@ function replyAdapter(id, name) {
7012
7027
  }
7013
7028
  };
7014
7029
  }
7030
+ var forwardSchema = strictObject({
7031
+ messageId: string2().min(1),
7032
+ to: union([string2(), array(string2())]),
7033
+ cc: union([string2(), array(string2())]).optional(),
7034
+ bcc: union([string2(), array(string2())]).optional(),
7035
+ from: string2().email().optional(),
7036
+ body: string2().max(1e5).optional().describe("A note above the forwarded message"),
7037
+ html: boolean2().optional(),
7038
+ draft: boolean2().optional(),
7039
+ attachments: array(absolutePath).max(20).optional(),
7040
+ noOriginalAttachments: boolean2().optional().describe("Leave out the original message's attachments")
7041
+ });
7015
7042
  var gmailForward = {
7016
7043
  id: "gmail.forward",
7017
7044
  service: "gmail",
7018
7045
  category: "send",
7019
- inputSchema: strictObject({
7020
- messageId: string2().min(1),
7021
- to: union([string2(), array(string2())]),
7022
- body: string2().max(1e5).optional(),
7023
- html: boolean2().optional()
7024
- }),
7046
+ inputSchema: forwardSchema,
7025
7047
  build(input, request) {
7026
7048
  const parsed = this.inputSchema.parse(input);
7027
7049
  const argv = ["gmail", "+forward", "--message-id", parsed.messageId, "--to", emails(parsed.to).join(",")];
7050
+ if (parsed.cc) argv.push("--cc", emails(parsed.cc).join(","));
7051
+ if (parsed.bcc) argv.push("--bcc", emails(parsed.bcc).join(","));
7052
+ if (parsed.from) argv.push("--from", parsed.from);
7028
7053
  if (parsed.body) argv.push("--body", parsed.body);
7029
7054
  if (parsed.html) argv.push("--html");
7055
+ if (parsed.draft) argv.push("--draft");
7056
+ if (parsed.noOriginalAttachments) argv.push("--no-original-attachments");
7057
+ const files = attach(argv, parsed.attachments);
7030
7058
  return {
7031
7059
  id: this.id,
7032
7060
  category: "send",
7033
7061
  argv,
7062
+ files,
7034
7063
  preview: {
7035
7064
  method: "gmail +forward",
7036
- params: { messageId: parsed.messageId, to: emails(parsed.to) },
7065
+ params: { messageId: parsed.messageId, to: emails(parsed.to), cc: emails(parsed.cc), bcc: emails(parsed.bcc), draft: parsed.draft ?? false },
7037
7066
  bodySummary: parsed.body ? bytes(parsed.body) : void 0,
7038
- recipients: emails(parsed.to),
7067
+ recipients: [...emails(parsed.to), ...emails(parsed.cc), ...emails(parsed.bcc)],
7068
+ uploads: parsed.attachments,
7039
7069
  account: request.account,
7040
7070
  category: "send"
7041
7071
  }
@@ -7049,7 +7079,8 @@ var gmailTriage = {
7049
7079
  inputSchema: strictObject({
7050
7080
  max: number2().int().positive().max(200).optional(),
7051
7081
  query: string2().max(2e3).optional(),
7052
- labels: boolean2().optional()
7082
+ labels: boolean2().optional(),
7083
+ format: outputFormat.optional()
7053
7084
  }),
7054
7085
  build(input, request) {
7055
7086
  const parsed = this.inputSchema.parse(input);
@@ -7057,6 +7088,7 @@ var gmailTriage = {
7057
7088
  if (parsed.max) argv.push("--max", String(parsed.max));
7058
7089
  if (parsed.query) argv.push("--query", parsed.query);
7059
7090
  if (parsed.labels) argv.push("--labels");
7091
+ if (parsed.format) argv.push("--format", parsed.format);
7060
7092
  return {
7061
7093
  id: this.id,
7062
7094
  category: "read",
@@ -7217,8 +7249,8 @@ var weeklyDigest = {
7217
7249
  var ADAPTERS = /* @__PURE__ */ new Map([
7218
7250
  [gmailSend.id, gmailSend],
7219
7251
  [gmailRead.id, gmailRead],
7220
- ["gmail.reply", replyAdapter("gmail.reply", "+reply")],
7221
- ["gmail.reply-all", replyAdapter("gmail.reply-all", "+reply-all")],
7252
+ ["gmail.reply", replyAdapter("gmail.reply", "+reply", false)],
7253
+ ["gmail.reply-all", replyAdapter("gmail.reply-all", "+reply-all", true)],
7222
7254
  [gmailForward.id, gmailForward],
7223
7255
  [gmailTriage.id, gmailTriage],
7224
7256
  [calendarAgenda.id, calendarAgenda],
@@ -7318,9 +7350,9 @@ function commitTool(category) {
7318
7350
  function words(query) {
7319
7351
  return (query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
7320
7352
  }
7321
- function startsWord(text3, token) {
7322
- for (let index2 = text3.indexOf(token); index2 !== -1; index2 = text3.indexOf(token, index2 + 1)) {
7323
- if (index2 === 0 || !/[a-z0-9]/.test(text3[index2 - 1])) return true;
7353
+ function startsWord(text4, token) {
7354
+ for (let index2 = text4.indexOf(token); index2 !== -1; index2 = text4.indexOf(token, index2 + 1)) {
7355
+ if (index2 === 0 || !/[a-z0-9]/.test(text4[index2 - 1])) return true;
7324
7356
  }
7325
7357
  return false;
7326
7358
  }
@@ -7634,12 +7666,14 @@ function defaultAccounts() {
7634
7666
  return { default: { mode: "interactive", enabledServices: services, access: "write" } };
7635
7667
  }
7636
7668
  var enabledServices = array(string2().min(1)).min(1);
7669
+ var domains = array(string2().regex(/^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+$/u, "must be a domain such as example.com")).min(1);
7637
7670
  var accountProfile = preprocess(
7638
7671
  (value) => value !== null && typeof value === "object" && !Array.isArray(value) && !("mode" in value) ? { ...value, mode: "interactive" } : value,
7639
7672
  discriminatedUnion("mode", [
7640
7673
  strictObject({
7641
7674
  mode: literal("interactive"),
7642
7675
  enabledServices,
7676
+ domains: domains.optional(),
7643
7677
  access: union([accessLevel, record(string2().min(1), accessLevel)]).optional(),
7644
7678
  scopes: array(string2().min(1)).optional(),
7645
7679
  oauthClient: OAuthClientSchema.optional()
@@ -7647,11 +7681,13 @@ var accountProfile = preprocess(
7647
7681
  strictObject({
7648
7682
  mode: literal("credentials_file"),
7649
7683
  enabledServices,
7684
+ domains: domains.optional(),
7650
7685
  credentialsFileEnv: envVarName
7651
7686
  }),
7652
7687
  strictObject({
7653
7688
  mode: literal("access_token"),
7654
7689
  enabledServices,
7690
+ domains: domains.optional(),
7655
7691
  tokenEnv: envVarName
7656
7692
  })
7657
7693
  ])
@@ -7775,6 +7811,7 @@ function loadConfig(raw) {
7775
7811
  accounts[alias] = {
7776
7812
  mode: profile.mode,
7777
7813
  enabledServices: enabled,
7814
+ ...profile.domains ? { domains: [...new Set(profile.domains.map((domain) => domain.toLowerCase()))] } : {},
7778
7815
  access,
7779
7816
  ...accessSpecified ? { accessSpecified } : {},
7780
7817
  scopes: [...new Set(scopes)],
@@ -7841,10 +7878,9 @@ function loadConfig(raw) {
7841
7878
  };
7842
7879
  }
7843
7880
 
7844
- // src/domain/version.ts
7845
- var PLUGIN_VERSION = "0.0.2";
7846
- var PLUGIN_ID = "google-workspace.gws";
7881
+ // src/domain/host.ts
7847
7882
  var HOST_RANGE = ">=2.2.3 <2.3.0";
7883
+ var TESTED_HOSTS = ["2.2.3", "2.2.4", "2.2.5"];
7848
7884
  var SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
7849
7885
  var RANGE = /^>=(\d+)\.(\d+)\.(\d+) <(\d+)\.(\d+)\.(\d+)$/;
7850
7886
  function compare(a, b) {
@@ -7863,18 +7899,20 @@ function satisfies(version2, range) {
7863
7899
  return compare(triple, floor) >= 0 && compare(triple, ceiling) < 0;
7864
7900
  }
7865
7901
  function checkHost(version2, range, allowUntested) {
7866
- const text3 = typeof version2 === "string" && version2.trim() ? version2.trim() : "unknown";
7867
- if (satisfies(text3, range)) return { version: text3 };
7868
- const problem = `tested with OpenCode ${range}, found ${text3}`;
7869
- if (!allowUntested) {
7902
+ const text4 = typeof version2 === "string" && version2.trim() ? version2.trim() : void 0;
7903
+ if (!(text4 && satisfies(text4, range)) && !allowUntested) {
7870
7904
  const [major, minor, patch] = bounds(range).floor;
7871
7905
  throw new Error(
7872
- `HOST_UNTESTED: Tested with OpenCode ${range}, found ${text3}. Next step: Use OpenCode ${major}.${minor}.${patch} or a later ${major}.${minor}.x release, or set "allowUntestedHost": true.`
7906
+ `HOST_UNTESTED: Supports OpenCode ${range}, found ${text4 ?? "unknown"}. Next step: Use OpenCode ${major}.${minor}.${patch} or a later ${major}.${minor}.x release, or set "allowUntestedHost": true.`
7873
7907
  );
7874
7908
  }
7875
- return { version: text3, warning: `${problem}; running anyway because allowUntestedHost is true` };
7909
+ return { version: text4 ?? null, range, tested: text4 !== void 0 && TESTED_HOSTS.includes(text4.replace(/^v/, "")) };
7876
7910
  }
7877
7911
 
7912
+ // src/domain/version.ts
7913
+ var PLUGIN_VERSION = "0.0.4";
7914
+ var PLUGIN_ID = "google-workspace.gws";
7915
+
7878
7916
  // src/host/agents.ts
7879
7917
  var ASK_ACTIONS = ["gws.write", "gws.export", "gws.send", "gws.share", "gws.delete", "gws.admin"];
7880
7918
  var ASK_RULES = ASK_ACTIONS.map((action) => ({ action, resource: "*", effect: "ask" }));
@@ -7950,6 +7988,7 @@ var object2 = (properties, required2) => ({ type: "object", properties, required
7950
7988
  var MODES = ["interactive", "credentials_file", "access_token"];
7951
7989
  var ACCESS = ["read", "write"];
7952
7990
  var CLIENT_SOURCES = ["account", "plugin", "pasted"];
7991
+ var STATES = ["connected", "sign_in_required", "not_configured", "host_untested"];
7953
7992
  var account = object2(
7954
7993
  {
7955
7994
  alias: text,
@@ -7969,9 +8008,9 @@ var account = object2(
7969
8008
  );
7970
8009
  var status = object2(
7971
8010
  {
7972
- pluginVersion: text,
7973
- hostVersion: text,
7974
- hostWarning: text,
8011
+ version: text,
8012
+ host: object2({ version: { type: ["string", "null"] }, range: text, tested: { type: "boolean" } }, ["version", "range", "tested"]),
8013
+ state: { enum: STATES },
7975
8014
  gws: object2({ path: text, version: text }, ["path", "version"]),
7976
8015
  services: texts,
7977
8016
  accounts: { type: "array", items: account },
@@ -7980,7 +8019,7 @@ var status = object2(
7980
8019
  permissionsBlock: text,
7981
8020
  nextStep: text
7982
8021
  },
7983
- ["pluginVersion", "hostVersion", "gws", "services", "accounts", "unguardedAgents"]
8022
+ ["version", "host", "state", "gws", "services", "accounts", "unguardedAgents", "nextStep"]
7984
8023
  );
7985
8024
  var Gws = Rpc.define({
7986
8025
  id: RPC_ID,
@@ -7998,7 +8037,8 @@ function connectionLabel(account2) {
7998
8037
  return account2.mode === "interactive" ? "sign-in required" : "not configured";
7999
8038
  }
8000
8039
  function openCodeLabel(status2) {
8001
- return status2.hostWarning ? `${status2.hostVersion} (host untested: ${status2.hostWarning})` : `${status2.hostVersion} (tested)`;
8040
+ const version2 = status2.host.version ?? "unknown";
8041
+ return status2.host.tested ? `${version2} (tested)` : `${version2} (host untested; supported range ${status2.host.range})`;
8002
8042
  }
8003
8043
  var DELEGATION_LABEL = "Needs domain-wide delegation";
8004
8044
  function delegationText(account2) {
@@ -8100,10 +8140,11 @@ var Lifecycle = class {
8100
8140
  // src/domain/plans.ts
8101
8141
  import { randomBytes } from "node:crypto";
8102
8142
  var MAX_PLANS = 512;
8103
- function redactedPreview(plan) {
8104
- const { method, recipients, destination, bodySummary, reason } = plan.preview;
8143
+ function redactedPreview(plan2) {
8144
+ const { method, recipients, recipientsUnknown, destination, bodySummary, reason } = plan2.preview;
8105
8145
  const view = { method };
8106
8146
  if (recipients && recipients.length > 0) view.recipients = [...recipients];
8147
+ if (recipientsUnknown !== void 0) view.recipientsUnknown = recipientsUnknown;
8107
8148
  if (destination !== void 0) view.destination = destination;
8108
8149
  if (bodySummary !== void 0) view.bodySummary = bodySummary;
8109
8150
  if (reason !== void 0) view.reason = reason;
@@ -8115,14 +8156,14 @@ var PlanStore = class {
8115
8156
  this.sweep();
8116
8157
  const now = Date.now();
8117
8158
  const { ttlSeconds, ...fields } = input;
8118
- const plan = {
8159
+ const plan2 = {
8119
8160
  ...fields,
8120
8161
  id: `plan_${randomBytes(18).toString("base64url")}`,
8121
8162
  state: "ready",
8122
8163
  createdAt: now,
8123
8164
  expiresAt: now + ttlSeconds * 1e3
8124
8165
  };
8125
- this.plans.set(plan.id, plan);
8166
+ this.plans.set(plan2.id, plan2);
8126
8167
  while (this.plans.size > MAX_PLANS) {
8127
8168
  const oldest = this.plans.keys().next().value;
8128
8169
  if (oldest === void 0) break;
@@ -8130,71 +8171,70 @@ var PlanStore = class {
8130
8171
  if (evicted) this.expire(evicted);
8131
8172
  this.plans.delete(oldest);
8132
8173
  }
8133
- return plan;
8174
+ return plan2;
8134
8175
  }
8135
8176
  get(id) {
8136
- const plan = this.plans.get(id);
8137
- if (!plan) return void 0;
8138
- if (plan.state === "ready" && Date.now() >= plan.expiresAt) this.expire(plan);
8139
- return plan;
8177
+ const plan2 = this.plans.get(id);
8178
+ if (!plan2) return void 0;
8179
+ if (plan2.state === "ready" && Date.now() >= plan2.expiresAt) this.expire(plan2);
8180
+ return plan2;
8140
8181
  }
8141
8182
  /** Ready (committable) plans, oldest first, optionally for one session. */
8142
8183
  list(filter = {}) {
8143
8184
  const ready = [];
8144
8185
  for (const id of [...this.plans.keys()]) {
8145
- const plan = this.get(id);
8146
- if (plan?.state !== "ready") continue;
8147
- if (filter.sessionId !== void 0 && plan.sessionId !== filter.sessionId) continue;
8148
- ready.push(plan);
8186
+ const plan2 = this.get(id);
8187
+ if (plan2?.state !== "ready") continue;
8188
+ if (filter.sessionId !== void 0 && plan2.sessionId !== filter.sessionId) continue;
8189
+ ready.push(plan2);
8149
8190
  }
8150
8191
  return ready.sort((a, b) => a.createdAt - b.createdAt);
8151
8192
  }
8152
8193
  /** Validate a commit binding without consuming the plan. */
8153
8194
  check(id, binding) {
8154
- const plan = this.get(id);
8155
- if (!plan) throw new GwsError("PLAN_NOT_FOUND", "run gws_plan first");
8156
- if (plan.state === "consumed") throw new GwsError("PLAN_ALREADY_USED", "create a new plan");
8157
- if (plan.state === "expired" || Date.now() >= plan.expiresAt) throw new GwsError("PLAN_EXPIRED", "create a new plan");
8158
- if (plan.state === "committing") throw new GwsError("PLAN_ALREADY_USED", "plan is committing", { next: "Wait for the running commit to finish; do not commit this plan again." });
8159
- if (plan.sessionId !== binding.sessionId || plan.agentId !== binding.agentId) {
8195
+ const plan2 = this.get(id);
8196
+ if (!plan2) throw new GwsError("PLAN_NOT_FOUND", "run gws_plan first");
8197
+ if (plan2.state === "consumed") {
8198
+ throw new GwsError("PLAN_ALREADY_USED", "this plan was already committed", { next: "Create a new plan to repeat the change; check the earlier result with gws_read first." });
8199
+ }
8200
+ if (plan2.state === "expired" || Date.now() >= plan2.expiresAt) throw new GwsError("PLAN_EXPIRED", "create a new plan");
8201
+ if (plan2.state === "committing") throw new GwsError("PLAN_ALREADY_USED", "plan is committing", { next: "Wait for the running commit to finish; do not commit this plan again." });
8202
+ if (plan2.sessionId !== binding.sessionId || plan2.agentId !== binding.agentId) {
8160
8203
  throw new GwsError("PLAN_CALLER_MISMATCH", "a plan is bound to the session and agent that created it");
8161
8204
  }
8162
- if (plan.category !== binding.category) {
8205
+ if (plan2.category !== binding.category) {
8163
8206
  throw new GwsError("CATEGORY_MISMATCH", "use the commit tool that matches the plan category", { next: "Commit with the tool named by the plan's category, passing only {planId}." });
8164
8207
  }
8165
- return plan;
8208
+ return plan2;
8166
8209
  }
8167
8210
  acquire(id, binding) {
8168
- const plan = this.check(id, binding);
8169
- if (plan.account !== binding.account) {
8211
+ const plan2 = this.check(id, binding);
8212
+ if (plan2.account !== binding.account) {
8170
8213
  throw new GwsError("PLAN_CALLER_MISMATCH", "a plan is bound to the account that created it");
8171
8214
  }
8172
- plan.state = "committing";
8173
- return plan;
8215
+ plan2.state = "committing";
8216
+ return plan2;
8174
8217
  }
8175
8218
  complete(id) {
8176
- const plan = this.plans.get(id);
8177
- if (plan?.state !== "committing") return;
8178
- plan.state = "consumed";
8219
+ const plan2 = this.plans.get(id);
8220
+ if (plan2?.state !== "committing") return;
8221
+ plan2.state = "consumed";
8179
8222
  }
8180
8223
  release(id) {
8181
- const plan = this.plans.get(id);
8182
- if (plan && plan.state === "committing") this.expire(plan);
8224
+ const plan2 = this.plans.get(id);
8225
+ if (plan2 && plan2.state === "committing") this.expire(plan2);
8183
8226
  }
8184
8227
  invalidateAll() {
8185
- for (const plan of this.plans.values()) this.expire(plan);
8228
+ for (const plan2 of this.plans.values()) this.expire(plan2);
8186
8229
  }
8187
- expire(plan) {
8188
- if (plan.state !== "ready" && plan.state !== "committing") return;
8189
- plan.state = "expired";
8230
+ expire(plan2) {
8231
+ if (plan2.state !== "ready" && plan2.state !== "committing") return;
8232
+ plan2.state = "expired";
8190
8233
  }
8191
8234
  sweep() {
8192
8235
  const now = Date.now();
8193
- for (const [id, plan] of this.plans) {
8194
- if (plan.state === "ready" && now >= plan.expiresAt) this.expire(plan);
8195
- if (plan.state === "consumed" || plan.state === "expired" || now >= plan.expiresAt) {
8196
- this.plans.delete(id);
8197
- }
8236
+ for (const [id, plan2] of this.plans) {
8237
+ if (now >= plan2.expiresAt && plan2.state !== "committing") this.plans.delete(id);
8198
8238
  }
8199
8239
  }
8200
8240
  };
@@ -8203,11 +8243,11 @@ var PlanStore = class {
8203
8243
  function isApiError(value) {
8204
8244
  if (Array.isArray(value)) return value.some(isApiError);
8205
8245
  if (!value || typeof value !== "object") return false;
8206
- const record2 = value;
8207
- return "error" in record2 && record2.error !== null && typeof record2.error === "object";
8246
+ const record3 = value;
8247
+ return "error" in record3 && record3.error !== null && typeof record3.error === "object";
8208
8248
  }
8209
- function parseJson(text3) {
8210
- const lines = text3.split("\n");
8249
+ function parseJson(text4) {
8250
+ const lines = text4.split("\n");
8211
8251
  const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
8212
8252
  if (start2 < 0) return void 0;
8213
8253
  const candidate = lines.slice(start2).join("\n").trim();
@@ -8231,28 +8271,42 @@ function inlineCap(maxInlineBytes, mode, requested) {
8231
8271
  return requested === void 0 ? base2 : Math.min(base2, requested);
8232
8272
  }
8233
8273
  function parseResult(result, options) {
8234
- const text3 = result.stdout.toString("utf8");
8235
- const parsed = parseJson(text3);
8236
- const serialized = parsed === void 0 ? text3 : JSON.stringify(parsed);
8274
+ const text4 = result.stdout.toString("utf8");
8275
+ const parsed = parseJson(text4);
8276
+ const serialized = parsed === void 0 ? text4 : JSON.stringify(parsed);
8237
8277
  const bytes2 = Buffer.byteLength(serialized);
8238
8278
  const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
8239
8279
  const truncated = bytes2 > cap;
8240
- const empty = text3.trim().length === 0;
8280
+ const empty = text4.trim().length === 0;
8241
8281
  return {
8242
8282
  // A successful DELETE commonly returns 204 with no body. Empty output
8243
8283
  // with exit 0 is success; non-JSON text is not a JSON result.
8244
8284
  ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
8245
8285
  data: parsed,
8246
- raw: parsed === void 0 ? text3.slice(0, cap) : void 0,
8286
+ raw: parsed === void 0 ? text4.slice(0, cap) : void 0,
8247
8287
  truncated,
8248
8288
  diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
8249
8289
  };
8250
8290
  }
8291
+ function withoutSavedBody(data, next) {
8292
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data;
8293
+ const record3 = data;
8294
+ const keys = Object.keys(record3);
8295
+ if (typeof record3["saved_file"] !== "string" || typeof record3["bytes"] !== "number" || record3["status"] !== "success") return data;
8296
+ if (!keys.every((key) => ["bytes", "mimeType", "saved_file", "status"].includes(key))) return data;
8297
+ if (record3["bytes"] === 0) return "";
8298
+ return {
8299
+ ...typeof record3["mimeType"] === "string" ? { mimeType: record3["mimeType"] } : {},
8300
+ bytes: record3["bytes"],
8301
+ saved: false,
8302
+ ...next ? { next } : {}
8303
+ };
8304
+ }
8251
8305
  function itemKey(page2) {
8252
8306
  if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
8253
- const record2 = page2;
8254
- if (Array.isArray(record2["items"])) return "items";
8255
- const arrays = Object.keys(record2).filter((key) => Array.isArray(record2[key]));
8307
+ const record3 = page2;
8308
+ if (Array.isArray(record3["items"])) return "items";
8309
+ const arrays = Object.keys(record3).filter((key) => Array.isArray(record3[key]));
8256
8310
  return arrays.length === 1 ? arrays[0] : void 0;
8257
8311
  }
8258
8312
  function capPageItems(data, maxItems) {
@@ -8272,11 +8326,11 @@ function capPageItems(data, maxItems) {
8272
8326
  kept.push(page2);
8273
8327
  continue;
8274
8328
  }
8275
- const record2 = page2;
8276
- const list = record2[key];
8329
+ const record3 = page2;
8330
+ const list = record3[key];
8277
8331
  const room = maxItems - items;
8278
8332
  if (list.length > room) {
8279
- const { nextPageToken: _dropped, ...rest } = record2;
8333
+ const { nextPageToken: _dropped, ...rest } = record3;
8280
8334
  kept.push({ ...rest, [key]: list.slice(0, room) });
8281
8335
  items += room;
8282
8336
  capped = true;
@@ -8315,29 +8369,33 @@ function stderrText(result) {
8315
8369
  var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
8316
8370
  var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
8317
8371
  var PRIVILEGE_REFUSALS = /\bnot authori[sz]ed to (?:read|access|view|list|use|manage|perform)\b|\bcaller does not have access to the customers? reporting data\b/i;
8318
- function privilegeRefused(result, parsed) {
8372
+ function privilegeRefused(result, service, parsed) {
8373
+ if (!isAdminService(service)) return false;
8319
8374
  if (result.timedOut || result.outputCapped || result.exitCode === 0 || result.exitCode === EXIT.auth) return false;
8320
8375
  const error2 = apiErrorOf(result, parsed);
8321
8376
  if (error2?.code !== void 0 && error2.code !== 401 && error2.code !== 403) return false;
8322
- const text3 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8323
- return !/invalid_grant/i.test(text3) && PRIVILEGE_REFUSALS.test(text3);
8377
+ const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8378
+ return !/invalid_grant/i.test(text4) && PRIVILEGE_REFUSALS.test(text4);
8379
+ }
8380
+ function isAdminService(service) {
8381
+ return service.startsWith("admin-");
8324
8382
  }
8325
8383
  var ADMIN_PRIVILEGES = { "admin-reports": "Reports" };
8326
8384
  function privilegeName(service) {
8327
8385
  const name = service ? ADMIN_PRIVILEGES[service] : void 0;
8328
8386
  return name ? `the ${name} privilege` : "the admin privilege it needs";
8329
8387
  }
8330
- function authProblem(result, parsed) {
8388
+ function authProblem(result, service, parsed) {
8331
8389
  if (result.timedOut || result.outputCapped) return void 0;
8332
- if (privilegeRefused(result, parsed)) return void 0;
8390
+ if (privilegeRefused(result, service, parsed)) return void 0;
8333
8391
  const error2 = apiErrorOf(result, parsed);
8334
8392
  if (result.exitCode === 0 && !error2) return void 0;
8335
8393
  const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8336
- const text3 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
8337
- if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text3)) {
8394
+ const text4 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
8395
+ if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text4)) {
8338
8396
  return "AUTH_SCOPE_MISSING";
8339
8397
  }
8340
- if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text3)) {
8398
+ if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text4)) {
8341
8399
  return "AUTH_REQUIRED";
8342
8400
  }
8343
8401
  return void 0;
@@ -8352,28 +8410,28 @@ var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<
8352
8410
  function disabledApi(result, parsed) {
8353
8411
  const error2 = apiErrorOf(result, parsed);
8354
8412
  const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8355
- const text3 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8356
- if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text3)) {
8413
+ const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8414
+ if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text4)) {
8357
8415
  return void 0;
8358
8416
  }
8359
- const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text3)?.[0])?.replace(/[.,;]+$/, "");
8360
- const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text3)?.[1];
8417
+ const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text4)?.[0])?.replace(/[.,;]+$/, "");
8418
+ const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text4)?.[1];
8361
8419
  return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
8362
8420
  }
8363
- function processError(result, parsed, remediation = GENERIC_REMEDIATION) {
8421
+ function processError(result, parsed, service, remediation = GENERIC_REMEDIATION) {
8364
8422
  if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
8365
8423
  if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
8366
8424
  const stderr = stderrText(result);
8367
8425
  const payload = apiErrorOf(result, parsed);
8368
8426
  const message = payload?.message || stderr || "gws command failed";
8369
- if (privilegeRefused(result, parsed)) {
8427
+ if (privilegeRefused(result, service, parsed)) {
8370
8428
  return new GwsError(
8371
8429
  "ADMIN_PRIVILEGE_REQUIRED",
8372
8430
  `Google accepted the sign-in but refused the call, because the account lacks a Workspace admin privilege; signing in again as the same account cannot help (gws: ${message.slice(0, 300)})`,
8373
8431
  { next: `${remediation.privilege.charAt(0).toUpperCase()}${remediation.privilege.slice(1)}.` }
8374
8432
  );
8375
8433
  }
8376
- const problem = authProblem(result, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
8434
+ const problem = authProblem(result, service, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
8377
8435
  if (problem === "AUTH_SCOPE_MISSING") {
8378
8436
  return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
8379
8437
  }
@@ -8397,16 +8455,16 @@ var COMMANDS = {
8397
8455
  status: { name: "gws-status", description: "Google Workspace: sign-in, access, pending plans, setup steps, and unguarded agents" },
8398
8456
  doctor: { name: "gws-doctor", description: "Google Workspace: check every account's services with one small read each" }
8399
8457
  };
8400
- function planSummary(plan) {
8458
+ function planSummary(plan2) {
8401
8459
  return {
8402
- planId: plan.id,
8403
- sessionID: plan.sessionId,
8404
- category: plan.category,
8405
- operation: plan.operation,
8406
- account: plan.account,
8407
- preview: redactedPreview(plan),
8408
- createdAt: new Date(plan.createdAt).toISOString(),
8409
- expiresAt: new Date(plan.expiresAt).toISOString()
8460
+ planId: plan2.id,
8461
+ sessionID: plan2.sessionId,
8462
+ category: plan2.category,
8463
+ operation: plan2.operation,
8464
+ account: plan2.account,
8465
+ preview: redactedPreview(plan2),
8466
+ createdAt: new Date(plan2.createdAt).toISOString(),
8467
+ expiresAt: new Date(plan2.expiresAt).toISOString()
8410
8468
  };
8411
8469
  }
8412
8470
  function summarizeStatus(status2, plans = []) {
@@ -8465,7 +8523,7 @@ function formatStatus(status2, plans = [], now = Date.now()) {
8465
8523
  lines.push(`Agents without default rules: ${status2.unguardedAgents.join(", ")}`, status2.permissionsBlock ?? "", BLOCK_PLACEMENT);
8466
8524
  }
8467
8525
  if (status2.auditDegraded) lines.push("Audit: some records could not be saved, so the audit log may be incomplete.");
8468
- lines.push(`OpenCode: ${openCodeLabel(status2)}`, `Next step: ${status2.nextStep ?? NOTHING_TO_DO}`);
8526
+ lines.push(`OpenCode: ${openCodeLabel(status2)}`, `Next step: ${status2.nextStep}`);
8469
8527
  return lines.join("\n");
8470
8528
  }
8471
8529
  function planLines(plans, now) {
@@ -8474,14 +8532,15 @@ function planLines(plans, now) {
8474
8532
  lines.push("No pending plans in this session.");
8475
8533
  } else {
8476
8534
  lines.push(`Pending plans in this session (${plans.length}):`);
8477
- for (const plan of plans) {
8535
+ for (const plan2 of plans) {
8478
8536
  const details = [
8479
- plan.preview.recipients?.length ? `to ${plan.preview.recipients.join(", ")}` : void 0,
8480
- plan.preview.destination ? `destination ${plan.preview.destination}` : void 0,
8481
- plan.preview.bodySummary ? `body ${plan.preview.bodySummary}` : void 0,
8482
- `expires ${remaining(plan.expiresAt, now)}`
8537
+ plan2.preview.recipients?.length ? `to ${plan2.preview.recipients.join(", ")}` : void 0,
8538
+ plan2.preview.recipientsUnknown ? `recipients unknown: ${plan2.preview.recipientsUnknown}` : void 0,
8539
+ plan2.preview.destination ? `destination ${plan2.preview.destination}` : void 0,
8540
+ plan2.preview.bodySummary ? `body ${plan2.preview.bodySummary}` : void 0,
8541
+ `expires ${remaining(plan2.expiresAt, now)}`
8483
8542
  ].filter(Boolean);
8484
- lines.push(`- ${plan.category} ${plan.operation} as ${plan.account}: ${details.join(" \xB7 ")} (${plan.planId})`);
8543
+ lines.push(`- ${plan2.category} ${plan2.operation} as ${plan2.account}: ${details.join(" \xB7 ")} (${plan2.planId})`);
8485
8544
  }
8486
8545
  lines.push("Approval happens only in the OpenCode permission prompt when the agent commits a plan. Pending plans expire automatically.");
8487
8546
  }
@@ -8530,8 +8589,8 @@ function formatDoctor(outcomes) {
8530
8589
  if (outcomes.length === 0) lines.push("No account enables a service.");
8531
8590
  for (const outcome of outcomes) {
8532
8591
  const next = nextFor(outcome);
8533
- const text3 = [outcome.detail, next ? `Next step: ${next.charAt(0).toUpperCase()}${next.slice(1)}` : void 0].filter(Boolean).join(". ");
8534
- lines.push(`- ${outcome.account} ${outcome.service}: ${outcome.status}${text3 ? ` \xB7 ${text3}` : ""}`);
8592
+ const text4 = [outcome.detail, next ? `Next step: ${next.charAt(0).toUpperCase()}${next.slice(1)}` : void 0].filter(Boolean).join(". ");
8593
+ lines.push(`- ${outcome.account} ${outcome.service}: ${outcome.status}${text4 ? ` \xB7 ${text4}` : ""}`);
8535
8594
  }
8536
8595
  return lines.join("\n");
8537
8596
  }
@@ -8542,9 +8601,9 @@ function summarizeDoctor(outcomes) {
8542
8601
  }
8543
8602
  async function registerCommands(ctx, sources, signal) {
8544
8603
  const lifetime = new Lifecycle();
8545
- const post = (sessionID, delivery, text3, description, command) => lifetime.wait(() => ctx.session.synthetic({
8604
+ const post = (sessionID, delivery, text4, description, command) => lifetime.wait(() => ctx.session.synthetic({
8546
8605
  sessionID,
8547
- text: text3,
8606
+ text: text4,
8548
8607
  description,
8549
8608
  metadata: { source: "opencode-gws", command },
8550
8609
  delivery,
@@ -8896,8 +8955,8 @@ function tokenError(status2, json, secrets) {
8896
8955
  message = scrubSecrets(message, secrets);
8897
8956
  return new Error(message);
8898
8957
  }
8899
- function scrubSecrets(text3, secrets) {
8900
- let out = text3;
8958
+ function scrubSecrets(text4, secrets) {
8959
+ let out = text4;
8901
8960
  for (const secret of secrets) {
8902
8961
  if (secret) out = out.split(secret).join("[redacted]");
8903
8962
  }
@@ -8919,15 +8978,15 @@ async function postToken(endpoint, body, fetchImpl, secrets) {
8919
8978
  } catch {
8920
8979
  throw new Error("Google token request failed: could not reach the token endpoint.");
8921
8980
  }
8922
- let text3 = "";
8981
+ let text4 = "";
8923
8982
  try {
8924
- text3 = await response.text();
8983
+ text4 = await response.text();
8925
8984
  } catch {
8926
8985
  }
8927
8986
  let json = {};
8928
- if (text3.trim()) {
8987
+ if (text4.trim()) {
8929
8988
  try {
8930
- const parsed = JSON.parse(text3);
8989
+ const parsed = JSON.parse(text4);
8931
8990
  if (isRecord(parsed)) json = parsed;
8932
8991
  } catch {
8933
8992
  if (response.ok) throw new Error("Google token endpoint returned an invalid response.");
@@ -9387,22 +9446,27 @@ function nextStep(accounts, agents) {
9387
9446
  if (agents.length > 0) return "Put the permission block first in the own permissions of each agent without default rules, followed by your top-level rules for gws actions.";
9388
9447
  return void 0;
9389
9448
  }
9449
+ function stateOf(accounts, tested) {
9450
+ if (accounts.some((account2) => account2.needsAuth || !account2.connected && account2.mode === "interactive")) return "sign_in_required";
9451
+ if (accounts.some((account2) => !account2.connected)) return "not_configured";
9452
+ return tested ? "connected" : "host_untested";
9453
+ }
9390
9454
  function statusSource(runtime, listAgents) {
9391
9455
  return async (signal) => {
9392
9456
  const base2 = await runtime.status(signal);
9393
9457
  const agents = unguardedAgents(await runtime.wait(() => listAgents(), signal));
9394
9458
  const next = nextStep(base2.accounts, agents);
9395
9459
  return {
9396
- pluginVersion: base2.pluginVersion,
9397
- hostVersion: base2.hostVersion,
9398
- ...base2.hostWarning ? { hostWarning: base2.hostWarning } : {},
9460
+ version: base2.version,
9461
+ host: base2.host,
9462
+ state: stateOf(base2.accounts, base2.host.tested),
9399
9463
  gws: { path: base2.binary.path, version: base2.binary.version },
9400
9464
  services: base2.services,
9401
9465
  accounts: base2.accounts,
9402
9466
  auditDegraded: base2.auditDegraded,
9403
9467
  unguardedAgents: agents,
9404
9468
  ...agents.length > 0 ? { permissionsBlock: permissionsBlock(agents) } : {},
9405
- ...next ? { nextStep: next } : {}
9469
+ nextStep: next ?? NOTHING_TO_DO
9406
9470
  };
9407
9471
  };
9408
9472
  }
@@ -9447,25 +9511,25 @@ function operationRequest(call2, action, target) {
9447
9511
  metadata: compact({ ...mark(call2), account: target.account, operation: target.operation, service: target.service, category: target.category })
9448
9512
  };
9449
9513
  }
9450
- function planMetadata(plan) {
9514
+ function planMetadata(plan2) {
9451
9515
  return compact({
9452
- planId: plan.id,
9453
- category: plan.category,
9454
- operation: plan.operation,
9455
- account: plan.account,
9456
- ...redactedPreview(plan),
9457
- expiresAt: new Date(plan.expiresAt).toISOString()
9516
+ planId: plan2.id,
9517
+ category: plan2.category,
9518
+ operation: plan2.operation,
9519
+ account: plan2.account,
9520
+ ...redactedPreview(plan2),
9521
+ expiresAt: new Date(plan2.expiresAt).toISOString()
9458
9522
  });
9459
9523
  }
9460
9524
  var SAVABLE = /* @__PURE__ */ new Set(["write", "export"]);
9461
- function commitRequests(call2, action, plan) {
9462
- const id = resource(plan.account, plan.operation);
9463
- const request = { ...base(call2, action), resources: [id], metadata: planMetadata(plan) };
9464
- if (SAVABLE.has(plan.category)) request.save = [id];
9525
+ function commitRequests(call2, action, plan2) {
9526
+ const id = resource(plan2.account, plan2.operation);
9527
+ const request = { ...base(call2, action), resources: [id], metadata: planMetadata(plan2) };
9528
+ if (SAVABLE.has(plan2.category)) request.save = [id];
9465
9529
  return [request];
9466
9530
  }
9467
- function compact(record2) {
9468
- return Object.fromEntries(Object.entries(record2).filter(([, value]) => value !== void 0));
9531
+ function compact(record3) {
9532
+ return Object.fromEntries(Object.entries(record3).filter(([, value]) => value !== void 0));
9469
9533
  }
9470
9534
 
9471
9535
  // src/host/tools.ts
@@ -9608,7 +9672,8 @@ var Preview = object({
9608
9672
  url: string2().nullable().optional(),
9609
9673
  params: JsonObject.describe("Parameters with secret-like values redacted"),
9610
9674
  bodySummary: string2().optional().describe("Body keys and size; never the body"),
9611
- recipients: array(string2()).optional(),
9675
+ recipients: array(string2()).optional().describe("Every address a send or share reaches, including those read for a reply, a draft or an event; *@domain for a whole domain"),
9676
+ recipientsUnknown: string2().optional().describe("Why the plugin could not tell everyone this send or share reaches"),
9612
9677
  uploads: array(string2()).optional().describe("Local files the commit uploads or attaches"),
9613
9678
  destination: string2().optional().describe("Local file the export writes"),
9614
9679
  overwrite: boolean2().optional().describe("The export replaces an existing file at destination"),
@@ -9649,17 +9714,23 @@ var AccountStatus = object({
9649
9714
  delegationRequired: array(string2()).optional().describe("Enabled services that need domain-wide delegation, which a Workspace admin sets up; the sign-in requests none of their scopes unless accounts.<alias>.scopes adds them"),
9650
9715
  nextStep: string2().optional().describe("What to do next; absent when nothing is needed")
9651
9716
  });
9717
+ var Host = object({
9718
+ version: string2().nullable().describe("The OpenCode version; null when unknown"),
9719
+ range: string2().describe("The supported range, as in package.json opencode.host"),
9720
+ tested: boolean2().describe("True only for a release the plugin is tested on")
9721
+ });
9722
+ var STATES2 = ["connected", "sign_in_required", "not_configured", "host_untested"];
9652
9723
  var StatusOutput = object({
9653
- pluginVersion: string2(),
9654
- hostVersion: string2(),
9655
- hostWarning: string2().optional().describe("Present when the OpenCode host is outside the tested range and allowUntestedHost is set"),
9724
+ version: string2().describe("The plugin's version"),
9725
+ host: Host,
9726
+ state: _enum(STATES2).describe("connected; sign_in_required or not_configured when an account needs it; host_untested on an untested OpenCode"),
9656
9727
  gws: object({ path: string2(), version: string2() }),
9657
9728
  services: array(string2()).describe("Model-facing services"),
9658
9729
  accounts: array(AccountStatus),
9659
9730
  auditDegraded: boolean2().describe("Audit records were dropped, failed, or exceeded the sink deadline; sticky until reload"),
9660
9731
  unguardedAgents: number2().int().describe("How many agents missed the default ask rules"),
9661
9732
  permissions: string2().optional().describe("Present when an agent missed the ask rules: where to find the fix"),
9662
- nextStep: string2().optional().describe("The one thing to do next; absent when nothing is needed")
9733
+ nextStep: string2().describe('The one thing to do next; "Nothing to do." when nothing is needed')
9663
9734
  });
9664
9735
 
9665
9736
  // src/host/tools.ts
@@ -9763,6 +9834,7 @@ function toolSpecs(runtime, assert2, status2) {
9763
9834
  report(call2, input.operation);
9764
9835
  const target = runtime.inspect(input, "plan");
9765
9836
  await authorize(call2, operationRequest(call2.host, "gws.plan", target));
9837
+ for (const operation of target.reads ?? []) await authorize(call2, operationRequest(call2.host, "gws.read", { ...target, operation, category: "read" }));
9766
9838
  const output = await runtime.plan(input, call2.runtime);
9767
9839
  return { output, metadata: { operation: target.operation, category: output.category, account: target.account } };
9768
9840
  }
@@ -9776,11 +9848,11 @@ function toolSpecs(runtime, assert2, status2) {
9776
9848
  input: CommitInput,
9777
9849
  output: CommitOutput,
9778
9850
  run: async (input, call2) => {
9779
- const plan = runtime.inspectPlan(category, input, call2.runtime);
9780
- report(call2, plan.operation);
9781
- for (const request of commitRequests(call2.host, `gws.${category}`, plan)) await authorize(call2, request);
9851
+ const plan2 = runtime.inspectPlan(category, input, call2.runtime);
9852
+ report(call2, plan2.operation);
9853
+ for (const request of commitRequests(call2.host, `gws.${category}`, plan2)) await authorize(call2, request);
9782
9854
  const output = await runtime.commit(category, input, call2.runtime);
9783
- return { output, metadata: { operation: plan.operation, category, account: plan.account } };
9855
+ return { output, metadata: { operation: plan2.operation, category, account: plan2.account } };
9784
9856
  }
9785
9857
  })
9786
9858
  )
@@ -9857,28 +9929,51 @@ function report(call2, operation) {
9857
9929
  call2.runtime.progress?.({ status: "resolving", operation });
9858
9930
  }
9859
9931
  function render(output, maxBytes) {
9860
- const text3 = JSON.stringify(output) ?? "";
9861
- const bytes2 = Buffer.byteLength(text3);
9862
- if (bytes2 <= maxBytes) return { text: text3, truncated: false };
9863
- const cut = Buffer.from(text3).subarray(0, maxBytes).toString("utf8");
9932
+ const text4 = JSON.stringify(output) ?? "";
9933
+ const bytes2 = Buffer.byteLength(text4);
9934
+ if (bytes2 <= maxBytes) return { text: text4, truncated: false };
9935
+ const cut = Buffer.from(text4).subarray(0, maxBytes).toString("utf8");
9864
9936
  return {
9865
9937
  text: `${cut}
9866
9938
  \u2026[truncated: ${maxBytes} of ${bytes2} bytes shown; narrow fields, params or pagination to see the rest]`,
9867
9939
  truncated: true
9868
9940
  };
9869
9941
  }
9870
- function compact2(record2) {
9871
- return Object.fromEntries(Object.entries(record2).filter(([, value]) => value !== void 0));
9942
+ function compact2(record3) {
9943
+ return Object.fromEntries(Object.entries(record3).filter(([, value]) => value !== void 0));
9872
9944
  }
9873
9945
 
9874
9946
  // src/host/scripting.ts
9875
9947
  var TOOLS_RPC_ID = `${RPC_ID}.tools`;
9876
9948
  var COMMIT_TOOLS = /* @__PURE__ */ new Set(["write", "send", "share", "delete", "export", "admin"]);
9877
9949
  var UNATTENDED = "A script cannot answer an approval prompt; this needs an allow rule for the session's agent.";
9878
- var Envelope = strictObject({
9879
- sessionID: string2().min(1),
9880
- input: unknown()
9881
- });
9950
+ var ENVELOPE_MESSAGES = {
9951
+ notObject: `the envelope must be an object, such as {"sessionID":"<session>","input":<the tool's input>}`,
9952
+ unknownKey: (key) => `"${key}" is not allowed`,
9953
+ sessionID: `"sessionID" must be a non-empty string`,
9954
+ inputMissing: `"input" is missing`,
9955
+ describe: "describe takes no input"
9956
+ };
9957
+ var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9958
+ function strict(schema, validate2) {
9959
+ return Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "opencode-gws", validate: validate2 }, enumerable: false });
9960
+ }
9961
+ var ENVELOPE = strict(
9962
+ { type: "object", properties: { sessionID: { type: "string", minLength: 1 }, input: {} }, required: ["sessionID", "input"], additionalProperties: false },
9963
+ (value) => {
9964
+ if (!plainObject(value)) return { issues: [{ message: ENVELOPE_MESSAGES.notObject }] };
9965
+ const found = [
9966
+ ...Object.keys(value).filter((key) => key !== "sessionID" && key !== "input").map(ENVELOPE_MESSAGES.unknownKey),
9967
+ ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [ENVELOPE_MESSAGES.sessionID],
9968
+ ..."input" in value ? [] : [ENVELOPE_MESSAGES.inputMissing]
9969
+ ];
9970
+ return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
9971
+ }
9972
+ );
9973
+ var DESCRIBE_INPUT = strict(
9974
+ { type: "object", properties: {}, additionalProperties: false },
9975
+ (value) => value === void 0 || value === null || plainObject(value) && Object.keys(value).length === 0 ? { value } : { issues: [{ message: ENVELOPE_MESSAGES.describe }] }
9976
+ );
9882
9977
  var Failure = strictObject({ code: string2(), next: string2() });
9883
9978
  var Description = strictObject({
9884
9979
  id: string2(),
@@ -9895,9 +9990,9 @@ var Description = strictObject({
9895
9990
  });
9896
9991
  function sessionNotFound(error2) {
9897
9992
  if (error2 === null || typeof error2 !== "object") return false;
9898
- const record2 = error2;
9899
- const tag = record2["_tag"] ?? record2["name"];
9900
- return tag === "Session.NotFoundError" || tag === "SessionNotFoundError" || record2["status"] === 404 || record2["statusCode"] === 404;
9993
+ const record3 = error2;
9994
+ const tag = record3["_tag"] ?? record3["name"];
9995
+ return tag === "Session.NotFoundError" || tag === "SessionNotFoundError" || record3["status"] === 404 || record3["statusCode"] === 404;
9901
9996
  }
9902
9997
  async function registerScripting(ctx, runtime, status2) {
9903
9998
  const assert2 = async (request) => {
@@ -9937,8 +10032,8 @@ async function registerScripting(ctx, runtime, status2) {
9937
10032
  const contract = Rpc2.define({
9938
10033
  id: TOOLS_RPC_ID,
9939
10034
  methods: {
9940
- describe: { input: strictObject({}).optional(), output: Description },
9941
- ...Object.fromEntries(specs.map((spec2) => [spec2.name, { input: Envelope, output: spec2.output, errors: { failed: Failure } }]))
10035
+ describe: { input: DESCRIBE_INPUT, output: Description },
10036
+ ...Object.fromEntries(specs.map((spec2) => [spec2.name, { input: ENVELOPE, output: spec2.output, errors: { failed: Failure } }]))
9942
10037
  },
9943
10038
  events: {}
9944
10039
  });
@@ -10041,12 +10136,12 @@ async function registerSkills(ctx, config2, visibleServices) {
10041
10136
  }
10042
10137
 
10043
10138
  // src/runtime/index.ts
10044
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10045
- import { tmpdir } from "node:os";
10046
- import { join as join8 } from "node:path";
10139
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "node:fs";
10140
+ import { tmpdir as tmpdir2 } from "node:os";
10141
+ import { join as join9 } from "node:path";
10047
10142
 
10048
10143
  // src/domain/audit.ts
10049
- import { writeFile, mkdir } from "node:fs/promises";
10144
+ import { mkdir, rename, stat, writeFile } from "node:fs/promises";
10050
10145
  import { write } from "node:fs";
10051
10146
  import { dirname as dirname3 } from "node:path";
10052
10147
  var EVENTS = /* @__PURE__ */ new Set(["plugin.loaded", "operation.read", "helper.read", "operation.plan", "operation.commit"]);
@@ -10055,6 +10150,7 @@ var UNKNOWN_ERROR = "UNKNOWN_ERROR";
10055
10150
  function auditErrorCode(error2) {
10056
10151
  return GwsError.from(error2)?.code ?? UNKNOWN_ERROR;
10057
10152
  }
10153
+ var AUDIT_MAX_BYTES = 5 * 1024 * 1024;
10058
10154
  var MAX_QUEUE = 64;
10059
10155
  var WRITE_TIMEOUT_MS = 100;
10060
10156
  var Audit = class {
@@ -10065,6 +10161,9 @@ var Audit = class {
10065
10161
  if (this.config.sink === "jsonl" && this.config.path) {
10066
10162
  await mkdir(dirname3(this.config.path), { recursive: true, mode: 448 });
10067
10163
  if (signal.aborted) return;
10164
+ const size = await stat(this.config.path).then((found) => found.size, () => 0);
10165
+ if (size > 0 && size + Buffer.byteLength(line) > AUDIT_MAX_BYTES) await rename(this.config.path, `${this.config.path}.1`);
10166
+ if (signal.aborted) return;
10068
10167
  await writeFile(this.config.path, line, { flag: "a", mode: 384, signal });
10069
10168
  } else {
10070
10169
  await writeStderr(line, signal);
@@ -10184,9 +10283,9 @@ function assertTrusted(path, projectDirectory) {
10184
10283
  if (!isAbsolute4(path)) throw new Error(`BINARY_TRUST_FAILED: Binary path must be absolute: ${path}`);
10185
10284
  if (!existsSync2(path)) throw new Error(`GWS_UNAVAILABLE: The binary.path option names a missing file: ${path}. Next step: S${OVERRIDE.slice(1)}.`);
10186
10285
  const real = realpathSync(path);
10187
- const stat2 = statSync(real);
10188
- if (!stat2.isFile()) throw new Error(`BINARY_TRUST_FAILED: Not a regular file: ${real}`);
10189
- if ((stat2.mode & 73) === 0 || (stat2.mode & 18) !== 0) {
10286
+ const stat3 = statSync(real);
10287
+ if (!stat3.isFile()) throw new Error(`BINARY_TRUST_FAILED: Not a regular file: ${real}`);
10288
+ if ((stat3.mode & 73) === 0 || (stat3.mode & 18) !== 0) {
10190
10289
  throw new Error("BINARY_TRUST_FAILED: Binary must be executable and not group/world writable");
10191
10290
  }
10192
10291
  if (projectDirectory && isUnder(resolve(projectDirectory), real)) {
@@ -10197,13 +10296,13 @@ function assertTrusted(path, projectDirectory) {
10197
10296
  if (typeof process.getgroups === "function") for (const gid of process.getgroups()) groups.add(gid);
10198
10297
  let dir = dirname4(real);
10199
10298
  for (; ; ) {
10200
- const stat3 = statSync(dir);
10201
- const mode = stat3.mode & 511;
10299
+ const stat4 = statSync(dir);
10300
+ const mode = stat4.mode & 511;
10202
10301
  if ((mode & 2) !== 0) {
10203
10302
  throw new Error(`BINARY_TRUST_FAILED: World-writable ancestor: ${dir} (mode ${mode.toString(8)})`);
10204
10303
  }
10205
- if ((mode & 16) !== 0 && !groups.has(stat3.gid)) {
10206
- throw new Error(`BINARY_TRUST_FAILED: Group-writable ancestor owned by a foreign group: ${dir} (mode ${mode.toString(8)}, gid ${stat3.gid})`);
10304
+ if ((mode & 16) !== 0 && !groups.has(stat4.gid)) {
10305
+ throw new Error(`BINARY_TRUST_FAILED: Group-writable ancestor owned by a foreign group: ${dir} (mode ${mode.toString(8)}, gid ${stat4.gid})`);
10207
10306
  }
10208
10307
  const parent = dirname4(dir);
10209
10308
  if (parent === dir) break;
@@ -10241,12 +10340,12 @@ function resolvePackagedBinary(platform = currentPlatform(), from = new URL("./p
10241
10340
  }
10242
10341
  const path = join3(dirname4(realpathSync(manifest)), "bin", "gws");
10243
10342
  if (!existsSync2(path)) throw new Error(`GWS_UNAVAILABLE: ${name} has no bin/gws. Next step: Reinstall the plugin, or ${OVERRIDE}.`);
10244
- const stat2 = lstatSync(path);
10245
- const mode = stat2.mode & 4095;
10246
- if (!stat2.isFile() || realpathSync(path) !== path || (mode & 320) !== 320 || (mode & ~493) !== 0) {
10343
+ const stat3 = lstatSync(path);
10344
+ const mode = stat3.mode & 4095;
10345
+ if (!stat3.isFile() || realpathSync(path) !== path || (mode & 320) !== 320 || (mode & ~493) !== 0) {
10247
10346
  throw new Error(`BINARY_TRUST_FAILED: ${name} bin/gws must be a regular file, not a link, with owner read/execute and no permissions outside 0755`);
10248
10347
  }
10249
- const identity = `${stat2.size}:${stat2.mtimeMs}:${stat2.ino}`;
10348
+ const identity = `${stat3.size}:${stat3.mtimeMs}:${stat3.ino}`;
10250
10349
  if (admitted.get(path) === identity) return path;
10251
10350
  if (createHash2("sha256").update(readFileSync3(path)).digest("hex") !== GWS_TARGETS[target].sha256) {
10252
10351
  throw new Error(`BINARY_TRUST_FAILED: ${name} bin/gws does not match the pinned ${target} release of gws 0.22.5`);
@@ -10432,6 +10531,62 @@ function runProcess(options) {
10432
10531
  });
10433
10532
  }
10434
10533
 
10534
+ // src/process/workdir.ts
10535
+ import { lstatSync as lstatSync2, mkdtempSync, readdirSync, readFileSync as readFileSync4, rmSync, writeFileSync } from "node:fs";
10536
+ import { tmpdir } from "node:os";
10537
+ import { join as join6 } from "node:path";
10538
+ var PREFIX2 = "opencode-gws-";
10539
+ var OWNER = "owner.pid";
10540
+ var DAY_MS = 24 * 60 * 60 * 1e3;
10541
+ var UNOWNED_MS = 7 * DAY_MS;
10542
+ function createWorkDir(root = tmpdir()) {
10543
+ const dir = mkdtempSync(join6(root, PREFIX2));
10544
+ writeFileSync(join6(dir, ".env"), "");
10545
+ writeFileSync(join6(dir, OWNER), `${process.pid}
10546
+ `, { mode: 384 });
10547
+ return dir;
10548
+ }
10549
+ function alive(pid) {
10550
+ try {
10551
+ process.kill(pid, 0);
10552
+ return true;
10553
+ } catch (error2) {
10554
+ return error2.code === "EPERM";
10555
+ }
10556
+ }
10557
+ function removeStaleWorkDirs(root = tmpdir(), now = Date.now()) {
10558
+ const removed = [];
10559
+ let names;
10560
+ try {
10561
+ names = readdirSync(root);
10562
+ } catch {
10563
+ return removed;
10564
+ }
10565
+ const uid = process.getuid?.();
10566
+ for (const name of names) {
10567
+ if (!/^opencode-gws-(?:probe-)?[A-Za-z0-9]{6}$/.test(name)) continue;
10568
+ const dir = join6(root, name);
10569
+ try {
10570
+ const stat3 = lstatSync2(dir);
10571
+ if (!stat3.isDirectory() || uid !== void 0 && stat3.uid !== uid) continue;
10572
+ const age = now - stat3.mtimeMs;
10573
+ if (age < DAY_MS) continue;
10574
+ let owner;
10575
+ try {
10576
+ owner = Number.parseInt(readFileSync4(join6(dir, OWNER), "utf8"), 10);
10577
+ } catch {
10578
+ owner = void 0;
10579
+ }
10580
+ const stale = owner !== void 0 && Number.isInteger(owner) && owner > 0 ? !alive(owner) : age >= UNOWNED_MS;
10581
+ if (!stale) continue;
10582
+ rmSync(dir, { recursive: true, force: true });
10583
+ removed.push(dir);
10584
+ } catch {
10585
+ }
10586
+ }
10587
+ return removed;
10588
+ }
10589
+
10435
10590
  // src/domain/schema.ts
10436
10591
  function parseSchemaOutput(id, stdout, stderr) {
10437
10592
  const start2 = stdout.indexOf("{");
@@ -10486,15 +10641,16 @@ var SchemaClient = class {
10486
10641
  };
10487
10642
 
10488
10643
  // src/runtime/accounts.ts
10489
- import { join as join6 } from "node:path";
10644
+ import { mkdtemp, rm } from "node:fs/promises";
10645
+ import { join as join7 } from "node:path";
10490
10646
 
10491
10647
  // src/domain/digest.ts
10492
10648
  function canonicalJson(value) {
10493
10649
  if (value === null || typeof value !== "object") return JSON.stringify(value);
10494
10650
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
10495
- const record2 = value;
10496
- const keys = Object.keys(record2).sort();
10497
- return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record2[key])}`).join(",")}}`;
10651
+ const record3 = value;
10652
+ const keys = Object.keys(record3).sort();
10653
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record3[key])}`).join(",")}}`;
10498
10654
  }
10499
10655
 
10500
10656
  // src/auth/doctor.ts
@@ -10537,17 +10693,17 @@ var DISABLED = /accessNotConfigured|SERVICE_DISABLED|has not been used in projec
10537
10693
  function probeOutcome(target, result) {
10538
10694
  const parsed = parseResult(result, { maxInlineBytes: 64 * 1024 });
10539
10695
  if (parsed.ok) return { ...target, status: "OK" };
10540
- if (privilegeRefused(result, parsed)) {
10696
+ if (privilegeRefused(result, target.service, parsed)) {
10541
10697
  return { ...target, status: "ADMIN_PRIVILEGE_REQUIRED", detail: "Google accepted the sign-in, but the account lacks the Workspace admin privilege this service needs" };
10542
10698
  }
10543
- const problem = authProblem(result, parsed);
10699
+ const problem = authProblem(result, target.service, parsed);
10544
10700
  if (problem === "AUTH_REQUIRED") return { ...target, status: problem, detail: "Google rejected the sign-in" };
10545
10701
  if (problem === "AUTH_SCOPE_MISSING") return { ...target, status: problem, detail: "Google says the sign-in lacks a scope for this service" };
10546
10702
  const error2 = apiError(parsed.data);
10547
- const text3 = `${error2?.message ?? ""}
10703
+ const text4 = `${error2?.message ?? ""}
10548
10704
  ${result.stderr.toString("utf8")}`;
10549
- if (DISABLED.test(text3) || error2?.reasons.some((reason) => DISABLED.test(reason))) {
10550
- const enableUrl = /https:\/\/console\.(?:developers|cloud)\.google\.com\/\S+/.exec(text3)?.[0]?.replace(/[.,)]+$/, "") ?? enableApisUrl([target.service]);
10705
+ if (DISABLED.test(text4) || error2?.reasons.some((reason) => DISABLED.test(reason))) {
10706
+ const enableUrl = /https:\/\/console\.(?:developers|cloud)\.google\.com\/\S+/.exec(text4)?.[0]?.replace(/[.,)]+$/, "") ?? enableApisUrl([target.service]);
10551
10707
  return { ...target, status: "API_DISABLED", detail: "the API is not enabled in the OAuth client's Google Cloud project", enableUrl };
10552
10708
  }
10553
10709
  if (PROBES[target.service]?.notFoundIsOk && (error2?.code === 404 || error2?.code === 400)) return { ...target, status: "OK" };
@@ -10641,9 +10797,8 @@ var Accounts = class {
10641
10797
  );
10642
10798
  return {
10643
10799
  auditDegraded: this.runtime.audit.degraded,
10644
- pluginVersion: options.pluginVersion,
10645
- hostVersion: options.hostVersion ?? "unknown",
10646
- ...options.hostWarning ? { hostWarning: options.hostWarning } : {},
10800
+ version: options.pluginVersion,
10801
+ host: options.host ?? { version: null, range: HOST_RANGE, tested: false },
10647
10802
  binary: { path: this.runtime.binary, version: this.runtime.version },
10648
10803
  services: [...this.runtime.visibleServices()].sort(),
10649
10804
  accounts
@@ -10668,9 +10823,15 @@ var Accounts = class {
10668
10823
  this.runtime.assertActive(ctx.signal);
10669
10824
  const env = buildChildEnv(profile, this.accountDir(account2), this.runtime.workDir, credential?.access);
10670
10825
  report2(ctx, { status: "running", operation });
10671
- const result = await this.spawn(argv, env, operation, ctx, options.paged === true, options.cwd ?? this.runtime.workDir);
10826
+ const scratch = options.cwd ? void 0 : await mkdtemp(join7(this.runtime.workDir, "call-"));
10827
+ let result;
10828
+ try {
10829
+ result = await this.spawn(argv, env, operation, ctx, options.paged === true, options.cwd ?? scratch);
10830
+ } finally {
10831
+ if (scratch) await rm(scratch, { recursive: true, force: true }).catch(() => void 0);
10832
+ }
10672
10833
  if (credential) {
10673
- const problem = authProblem(result);
10834
+ const problem = authProblem(result, serviceOf(operation));
10674
10835
  if (problem) this.observeAuth(account2, { status: "needs_auth", code: problem, message: this.authMessage(account2, problem, options.scopes) });
10675
10836
  else if (result.exitCode === 0 && options.dryRun !== true) this.observeAuth(account2, { status: "ok" });
10676
10837
  }
@@ -10723,7 +10884,7 @@ var Accounts = class {
10723
10884
  }
10724
10885
  /** The plugin-owned per-account gws directory: its Discovery cache, and in credentials_file mode its token cache. */
10725
10886
  accountDir(account2) {
10726
- return ownedDir(join6(this.runtime.paths.cacheDir, "accounts", this.integrationId(account2).replace(/^gws-/, "")));
10887
+ return ownedDir(join7(this.runtime.paths.cacheDir, "accounts", this.integrationId(account2).replace(/^gws-/, "")));
10727
10888
  }
10728
10889
  /** Remediation text for an account's sign-in, consent and admin privilege problems, by credential mode. */
10729
10890
  remediation(account2, scopes, service) {
@@ -10816,16 +10977,20 @@ function delegationField(profile) {
10816
10977
  const services = delegationRequired(profile.enabledServices, profile.scopes);
10817
10978
  return services.length > 0 ? { delegationRequired: services } : {};
10818
10979
  }
10819
- function capitalize(text3) {
10820
- return text3.charAt(0).toUpperCase() + text3.slice(1);
10980
+ function serviceOf(operation) {
10981
+ return operation.slice(0, operation.indexOf("."));
10982
+ }
10983
+ function capitalize(text4) {
10984
+ return text4.charAt(0).toUpperCase() + text4.slice(1);
10821
10985
  }
10822
10986
 
10823
10987
  // src/domain/classify.ts
10824
- var NOTIFYING_UPDATES = /* @__PURE__ */ new Set(["all", "externalonly"]);
10825
10988
  function emailsAttendees(entry, request) {
10826
10989
  if (entry.service !== "calendar" || entry.path.join(".") !== "events") return false;
10827
- const value = request.params?.["sendUpdates"];
10828
- return typeof value === "string" && NOTIFYING_UPDATES.has(value.toLowerCase());
10990
+ const updates = request.params?.["sendUpdates"];
10991
+ const legacy = request.params?.["sendNotifications"];
10992
+ const off = (value, none) => value === void 0 || value === false || typeof value === "string" && value.toLowerCase() === none;
10993
+ return !off(updates, "none") || !off(legacy, "false");
10829
10994
  }
10830
10995
  function effectiveCategory(entry, request) {
10831
10996
  if (entry.category === "deny" || entry.schemaError) return "deny";
@@ -10841,27 +11006,393 @@ function isCommittable(category) {
10841
11006
 
10842
11007
  // src/domain/command.ts
10843
11008
  import { isAbsolute as isAbsolute6 } from "node:path";
10844
- function recipientsOf(request) {
10845
- const out = /* @__PURE__ */ new Set();
10846
- const visit = (value, key) => {
11009
+
11010
+ // src/domain/recipients.ts
11011
+ function addressesIn(text4) {
11012
+ const addresses = [];
11013
+ const unreadable = [];
11014
+ for (const match of text4.matchAll(/([A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]*)@(\[[^\]]*\]?|[^\s<>()[\]\\,;:"@]*)/g)) {
11015
+ const local = match[1].replace(/^\.+/, "");
11016
+ const domain = match[2].toLowerCase().replace(/\.+$/, "");
11017
+ if (!local && !domain) continue;
11018
+ if (!local || !/^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+$/u.test(domain)) {
11019
+ unreadable.push(match[0]);
11020
+ continue;
11021
+ }
11022
+ addresses.push(`${local.toLowerCase()}@${domain}`);
11023
+ }
11024
+ return { addresses, unreadable };
11025
+ }
11026
+ function domainOf(address) {
11027
+ return address.slice(address.lastIndexOf("@") + 1);
11028
+ }
11029
+ var Collector = class {
11030
+ addresses = /* @__PURE__ */ new Set();
11031
+ domains = /* @__PURE__ */ new Set();
11032
+ unknown;
11033
+ public;
11034
+ text(value) {
10847
11035
  if (typeof value === "string") {
10848
- if (key && /^(to|cc|bcc|email|emailAddress|address|recipient)$/i.test(key) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
10849
- out.add(value.toLowerCase());
10850
- }
11036
+ const found = addressesIn(value);
11037
+ for (const address of found.addresses) this.addresses.add(address);
11038
+ if (found.unreadable.length > 0) this.fail(`an address could not be read: ${found.unreadable[0]}`);
11039
+ return;
11040
+ }
11041
+ if (Array.isArray(value)) for (const item of value) this.text(item);
11042
+ }
11043
+ domain(value) {
11044
+ if (typeof value === "string" && /^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+$/u.test(value.trim())) this.domains.add(value.trim().toLowerCase());
11045
+ else this.fail("the domain it shares with could not be read");
11046
+ }
11047
+ add(set) {
11048
+ for (const address of set.addresses) this.addresses.add(address);
11049
+ for (const domain of set.domains) this.domains.add(domain);
11050
+ }
11051
+ fail(reason) {
11052
+ this.unknown ??= reason;
11053
+ }
11054
+ result() {
11055
+ return {
11056
+ addresses: [...this.addresses],
11057
+ domains: [...this.domains],
11058
+ ...this.unknown ? { unknown: this.unknown } : {},
11059
+ ...this.public ? { public: true } : {}
11060
+ };
11061
+ }
11062
+ };
11063
+ var RECIPIENT_KEYS = /* @__PURE__ */ new Set(["to", "cc", "bcc", "email", "emailaddress", "address", "recipient", "recipients", "attendees", "delegateemail", "forwardingemail"]);
11064
+ function scanRecipients(request) {
11065
+ const out = new Collector();
11066
+ const visit = (value, collecting) => {
11067
+ if (typeof value === "string") {
11068
+ if (collecting) out.text(value);
10851
11069
  return;
10852
11070
  }
10853
11071
  if (Array.isArray(value)) {
10854
- for (const item of value) visit(item, key);
11072
+ for (const item of value) visit(item, collecting);
10855
11073
  return;
10856
11074
  }
10857
11075
  if (value && typeof value === "object") {
10858
- for (const [childKey, child] of Object.entries(value)) visit(child, childKey);
11076
+ for (const [key, child] of Object.entries(value)) visit(child, collecting || RECIPIENT_KEYS.has(key.toLowerCase()));
11077
+ }
11078
+ };
11079
+ visit(request.params, false);
11080
+ visit(request.body, false);
11081
+ visit(request.input, false);
11082
+ return out.result();
11083
+ }
11084
+ function record2(value) {
11085
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
11086
+ }
11087
+ function text3(value) {
11088
+ return typeof value === "string" && value.length > 0 ? value : void 0;
11089
+ }
11090
+ function mimeRecipients(raw) {
11091
+ if (typeof raw !== "string" || raw.length === 0 || !/^[A-Za-z0-9+/_=\s-]+$/.test(raw)) return void 0;
11092
+ const message = Buffer.from(raw.replace(/\s+/g, ""), "base64").toString("utf8");
11093
+ const end = /\r?\n\r?\n/.exec(message);
11094
+ const head = (end ? message.slice(0, end.index) : message).replace(/\r?\n[ \t]+/g, " ");
11095
+ const lines = head.split(/\r?\n/);
11096
+ if (!lines.every((line) => line === "" || /^[!-9;-~]+[ \t]*:/.test(line))) return void 0;
11097
+ return lines.filter((line) => /^(?:to|cc|bcc)[ \t]*:/i.test(line)).map((line) => line.slice(line.indexOf(":") + 1));
11098
+ }
11099
+ function headers(message) {
11100
+ const list = record2(record2(message)["payload"])["headers"];
11101
+ if (!Array.isArray(list)) return void 0;
11102
+ const out = /* @__PURE__ */ new Map();
11103
+ for (const header of list) {
11104
+ const name = text3(record2(header)["name"])?.toLowerCase();
11105
+ const value = record2(header)["value"];
11106
+ if (name && typeof value === "string") out.set(name, [...out.get(name) ?? [], value]);
11107
+ }
11108
+ return out;
11109
+ }
11110
+ function setOf(values) {
11111
+ const out = new Collector();
11112
+ out.text(values);
11113
+ return out.unknown ? void 0 : { addresses: [...out.addresses], domains: [] };
11114
+ }
11115
+ function plan(out, lookups = [], needsSelf = false) {
11116
+ return { known: out.result(), lookups, ...needsSelf ? { needsSelf: true } : {} };
11117
+ }
11118
+ function helperSend(fields) {
11119
+ return (request) => {
11120
+ const input = record2(request.input);
11121
+ const out = new Collector();
11122
+ for (const field of fields) out.text(input[field]);
11123
+ if (out.addresses.size === 0) out.fail("no recipient address was found");
11124
+ return plan(out);
11125
+ };
11126
+ }
11127
+ function replySpec(all) {
11128
+ return (request) => {
11129
+ const input = record2(request.input);
11130
+ const out = new Collector();
11131
+ for (const field of ["to", "cc", "bcc"]) out.text(input[field]);
11132
+ const removed = new Set(addressesIn([input["remove"]].flat().filter((item) => typeof item === "string").join(",")).addresses);
11133
+ const messageId = text3(input["messageId"]);
11134
+ if (!messageId) {
11135
+ out.fail("the message being replied to is not named");
11136
+ return plan(out);
11137
+ }
11138
+ const lookup = {
11139
+ operation: "gmail.users.messages.get",
11140
+ params: { userId: "me", id: messageId, format: "metadata", fields: "payload/headers" },
11141
+ what: "the message being replied to",
11142
+ extract(data, self) {
11143
+ const found = headers(data);
11144
+ if (!found) return void 0;
11145
+ const sender = found.get("reply-to") ?? found.get("from") ?? [];
11146
+ const senderSet = setOf(sender);
11147
+ if (!senderSet) return void 0;
11148
+ const values = [...sender];
11149
+ if (all || self !== void 0 && senderSet.addresses.includes(self)) values.push(...found.get("to") ?? []);
11150
+ if (all) values.push(...found.get("cc") ?? []);
11151
+ const set = setOf(values);
11152
+ if (!set) return void 0;
11153
+ const kept = all ? set.addresses.filter((address) => address !== self && !removed.has(address)) : set.addresses;
11154
+ return { addresses: kept, domains: [] };
11155
+ }
11156
+ };
11157
+ return plan(out, [lookup], true);
11158
+ };
11159
+ }
11160
+ function rawInto(out, raw) {
11161
+ const values = mimeRecipients(raw);
11162
+ if (values) out.text(values);
11163
+ else out.fail("the raw message has no readable header block");
11164
+ }
11165
+ var messagesSend = (request) => {
11166
+ const out = new Collector();
11167
+ if (request.upload) out.fail("the message is in an uploaded file");
11168
+ else if (record2(request.body)["raw"] === void 0) out.fail("the message has no raw field");
11169
+ else rawInto(out, record2(request.body)["raw"]);
11170
+ if (!out.unknown && out.addresses.size === 0) out.fail("the raw message has no To, Cc or Bcc header");
11171
+ return plan(out);
11172
+ };
11173
+ var draftsSend = (request) => {
11174
+ const out = new Collector();
11175
+ const body = record2(request.body);
11176
+ const raw = record2(body["message"])["raw"];
11177
+ if (raw !== void 0) rawInto(out, raw);
11178
+ const id = text3(body["id"]);
11179
+ if (!id) {
11180
+ out.fail("the draft is not named (body.id)");
11181
+ return plan(out);
11182
+ }
11183
+ const userId = text3(record2(request.params)["userId"]) ?? "me";
11184
+ const lookup = {
11185
+ operation: "gmail.users.drafts.get",
11186
+ params: { userId, id, format: "metadata", fields: "message/payload/headers" },
11187
+ what: "the draft",
11188
+ none: "the draft could not be read or names no recipient",
11189
+ extract(data) {
11190
+ const found = headers(record2(data)["message"]);
11191
+ return found ? setOf([...found.get("to") ?? [], ...found.get("cc") ?? [], ...found.get("bcc") ?? []]) : void 0;
10859
11192
  }
10860
11193
  };
10861
- visit(request.params);
10862
- visit(request.body);
10863
- return [...out];
11194
+ return plan(out, [lookup]);
11195
+ };
11196
+ function attendeesInto(out, body) {
11197
+ const attendees = record2(body)["attendees"];
11198
+ if (attendees === void 0) return;
11199
+ if (!Array.isArray(attendees)) {
11200
+ out.fail("the attendees could not be read");
11201
+ return;
11202
+ }
11203
+ for (const attendee of attendees) {
11204
+ const email2 = record2(attendee)["email"];
11205
+ if (typeof email2 === "string") out.text(email2);
11206
+ else out.fail("an attendee has no email");
11207
+ }
10864
11208
  }
11209
+ var eventInsert = (request) => {
11210
+ const out = new Collector();
11211
+ attendeesInto(out, request.body);
11212
+ return plan(out);
11213
+ };
11214
+ var eventChange = (request, category) => {
11215
+ const out = new Collector();
11216
+ attendeesInto(out, request.body);
11217
+ if (category !== "send") return plan(out);
11218
+ const params = record2(request.params);
11219
+ const calendarId = text3(params["calendarId"]);
11220
+ const eventId = text3(params["eventId"]);
11221
+ if (!calendarId || !eventId) {
11222
+ out.fail("the event is not named (calendarId and eventId)");
11223
+ return plan(out);
11224
+ }
11225
+ const lookup = {
11226
+ operation: "calendar.events.get",
11227
+ params: { calendarId, eventId, fields: "attendees(email)" },
11228
+ what: "the event's guests",
11229
+ extract(data) {
11230
+ const attendees = record2(data)["attendees"] ?? [];
11231
+ if (!Array.isArray(attendees)) return void 0;
11232
+ const emails2 = attendees.map((attendee) => record2(attendee)["email"]);
11233
+ return emails2.every((email2) => typeof email2 === "string") ? setOf(emails2) : void 0;
11234
+ }
11235
+ };
11236
+ return plan(out, [lookup]);
11237
+ };
11238
+ var quickAdd = () => {
11239
+ const out = new Collector();
11240
+ out.fail("Google reads the guests from the text");
11241
+ return plan(out);
11242
+ };
11243
+ function drivePermissionInto(out, permission) {
11244
+ const type = text3(permission["type"]);
11245
+ if (type === "anyone") return;
11246
+ if (type === "domain") out.domain(permission["domain"]);
11247
+ else if (typeof permission["emailAddress"] === "string") out.text(permission["emailAddress"]);
11248
+ else out.fail("the permission names no emailAddress");
11249
+ }
11250
+ var drivePermissionCreate = (request) => {
11251
+ const out = new Collector();
11252
+ drivePermissionInto(out, record2(request.body));
11253
+ return plan(out);
11254
+ };
11255
+ var drivePermissionUpdate = (request) => {
11256
+ const out = new Collector();
11257
+ const params = record2(request.params);
11258
+ const fileId = text3(params["fileId"]);
11259
+ const permissionId = text3(params["permissionId"]);
11260
+ if (!fileId || !permissionId) {
11261
+ out.fail("the permission is not named (fileId and permissionId)");
11262
+ return plan(out);
11263
+ }
11264
+ const lookup = {
11265
+ operation: "drive.permissions.get",
11266
+ // The request's own parameters, so a shared-drive flag it carries applies to the read too.
11267
+ params: { ...params, fields: "type,emailAddress,domain" },
11268
+ what: "the permission",
11269
+ extract(data) {
11270
+ const found = new Collector();
11271
+ drivePermissionInto(found, record2(data));
11272
+ return found.unknown ? void 0 : found.result();
11273
+ }
11274
+ };
11275
+ return plan(out, [lookup]);
11276
+ };
11277
+ function aclScopeInto(out, type, value) {
11278
+ if (type === "default") out.public = true;
11279
+ else if (type === "domain") out.domain(value);
11280
+ else if ((type === "user" || type === "group") && typeof value === "string") out.text(value);
11281
+ else out.fail("the ACL scope could not be read");
11282
+ }
11283
+ var aclChange = (request) => {
11284
+ const out = new Collector();
11285
+ const scope = record2(record2(request.body)["scope"]);
11286
+ if (Object.keys(scope).length > 0) aclScopeInto(out, text3(scope["type"]), scope["value"]);
11287
+ const ruleId = text3(record2(request.params)["ruleId"]);
11288
+ if (ruleId) {
11289
+ const colon = ruleId.indexOf(":");
11290
+ aclScopeInto(out, colon < 0 ? ruleId : ruleId.slice(0, colon), colon < 0 ? void 0 : ruleId.slice(colon + 1));
11291
+ }
11292
+ if (Object.keys(scope).length === 0 && !ruleId) out.fail("the ACL scope could not be read");
11293
+ return plan(out);
11294
+ };
11295
+ function bodyAddress(field, optional2) {
11296
+ return (request) => {
11297
+ const out = new Collector();
11298
+ const body = record2(request.body);
11299
+ if (optional2?.(body)) return plan(out);
11300
+ const value = body[field];
11301
+ if (typeof value === "string" && value.includes("@")) out.text(value);
11302
+ else out.fail(`body.${field} is not an email address`);
11303
+ return plan(out);
11304
+ };
11305
+ }
11306
+ var chatMember = (request) => {
11307
+ const out = new Collector();
11308
+ const name = text3(record2(record2(request.body)["member"])["name"]);
11309
+ if (name?.startsWith("users/") && name.includes("@")) out.text(name.slice("users/".length));
11310
+ else out.fail("the member is not named by email (users/<email>)");
11311
+ return plan(out);
11312
+ };
11313
+ var keepPermissions = (request) => {
11314
+ const out = new Collector();
11315
+ const requests = record2(request.body)["requests"];
11316
+ if (!Array.isArray(requests) || requests.length === 0) out.fail("the permission requests could not be read");
11317
+ else {
11318
+ for (const item of requests) {
11319
+ const email2 = record2(record2(item)["permission"])["email"];
11320
+ if (typeof email2 === "string") out.text(email2);
11321
+ else out.fail("a permission names no email");
11322
+ }
11323
+ }
11324
+ return plan(out);
11325
+ };
11326
+ var revoke = () => plan(new Collector());
11327
+ var SPECS = {
11328
+ "gmail.send": helperSend(["to", "cc", "bcc"]),
11329
+ "gmail.forward": helperSend(["to", "cc", "bcc"]),
11330
+ "gmail.reply": replySpec(false),
11331
+ "gmail.reply-all": replySpec(true),
11332
+ "gmail.users.messages.send": messagesSend,
11333
+ "gmail.users.drafts.send": draftsSend,
11334
+ "gmail.users.settings.delegates.create": bodyAddress("delegateEmail"),
11335
+ "gmail.users.settings.forwardingAddresses.create": bodyAddress("forwardingEmail"),
11336
+ "gmail.users.settings.updateAutoForwarding": bodyAddress("emailAddress", (body) => body["enabled"] === false),
11337
+ "gmail.users.settings.delegates.delete": revoke,
11338
+ "gmail.users.settings.forwardingAddresses.delete": revoke,
11339
+ "calendar.events.insert": eventInsert,
11340
+ "calendar.events.import": eventInsert,
11341
+ "calendar.events.quickAdd": quickAdd,
11342
+ "calendar.events.update": eventChange,
11343
+ "calendar.events.patch": eventChange,
11344
+ "calendar.events.delete": eventChange,
11345
+ "calendar.events.move": eventChange,
11346
+ "calendar.acl.insert": aclChange,
11347
+ "calendar.acl.update": aclChange,
11348
+ "calendar.acl.patch": aclChange,
11349
+ "calendar.acl.delete": revoke,
11350
+ "drive.permissions.create": drivePermissionCreate,
11351
+ "drive.permissions.update": drivePermissionUpdate,
11352
+ "drive.permissions.delete": revoke,
11353
+ "chat.spaces.members.create": chatMember,
11354
+ "chat.spaces.members.delete": revoke,
11355
+ "classroom.invitations.create": bodyAddress("userId"),
11356
+ "classroom.invitations.delete": revoke,
11357
+ "classroom.courses.studentGroups.studentGroupMembers.create": bodyAddress("userId"),
11358
+ "meet.spaces.members.create": bodyAddress("email"),
11359
+ "keep.notes.permissions.batchCreate": keepPermissions,
11360
+ "keep.notes.permissions.batchDelete": revoke
11361
+ };
11362
+ function recipientPlan(operation, category, request) {
11363
+ const spec2 = SPECS[operation];
11364
+ if (spec2) return spec2(request, category);
11365
+ const scanned = scanRecipients(request);
11366
+ if (category === "send" || category === "share") {
11367
+ return { known: { ...scanned, unknown: scanned.unknown ?? `the plugin cannot tell who ${operation} reaches` }, lookups: [] };
11368
+ }
11369
+ return { known: scanned, lookups: [] };
11370
+ }
11371
+ function withLookup(recipients, found, lookup) {
11372
+ if (!found) return { ...recipients, unknown: recipients.unknown ?? `${lookup.what} could not be read` };
11373
+ const merged = {
11374
+ ...recipients,
11375
+ addresses: [.../* @__PURE__ */ new Set([...recipients.addresses, ...found.addresses])],
11376
+ domains: [.../* @__PURE__ */ new Set([...recipients.domains, ...found.domains])]
11377
+ };
11378
+ if (lookup.none && merged.addresses.length === 0 && merged.domains.length === 0) merged.unknown ??= lookup.none;
11379
+ return merged;
11380
+ }
11381
+ function recipientLabels(recipients) {
11382
+ return [...recipients.addresses, ...recipients.domains.map((domain) => `*@${domain}`)];
11383
+ }
11384
+ var ACCOUNT_ADDRESS_READS = {
11385
+ gmail: { operation: "gmail.users.getProfile", params: { userId: "me", fields: "emailAddress" }, extract: (data) => ownAddress(record2(data)["emailAddress"]) },
11386
+ drive: { operation: "drive.about.get", params: { fields: "user(emailAddress)" }, extract: (data) => ownAddress(record2(record2(data)["user"])["emailAddress"]) },
11387
+ calendar: { operation: "calendar.calendars.get", params: { calendarId: "primary", fields: "id" }, extract: (data) => ownAddress(record2(data)["id"]) }
11388
+ };
11389
+ function ownAddress(value) {
11390
+ if (typeof value !== "string") return void 0;
11391
+ const found = addressesIn(value);
11392
+ return found.addresses.length === 1 && found.unreadable.length === 0 ? found.addresses[0] : void 0;
11393
+ }
11394
+
11395
+ // src/domain/command.ts
10865
11396
  function wantsPages(request) {
10866
11397
  const pagination = request.pagination ?? {};
10867
11398
  return pagination.all === true || pagination.maxPages !== void 0 || pagination.maxItems !== void 0;
@@ -10940,7 +11471,7 @@ function buildCommand(entry, request, config2, options) {
10940
11471
  url: entry.pathTemplate,
10941
11472
  params: redactParams(params),
10942
11473
  bodySummary: request.body === void 0 ? void 0 : summarizeBody(request.body),
10943
- recipients: recipientsOf(request),
11474
+ recipients: recipientLabels(scanRecipients(request)),
10944
11475
  uploads: request.upload ? [request.upload.path] : void 0,
10945
11476
  destination: request.download?.path,
10946
11477
  overwrite: request.download?.overwrite === true ? true : void 0,
@@ -10978,32 +11509,6 @@ function matches2(rule, category, account2, service, operation) {
10978
11509
  function underRoot2(path, roots) {
10979
11510
  return roots.some((root) => underRoot(path, root));
10980
11511
  }
10981
- function collectEmails(value, out) {
10982
- if (typeof value === "string") {
10983
- if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) out.add(value.toLowerCase());
10984
- return;
10985
- }
10986
- if (Array.isArray(value)) {
10987
- for (const item of value) collectEmails(item, out);
10988
- return;
10989
- }
10990
- if (value && typeof value === "object") {
10991
- for (const [key, item] of Object.entries(value)) {
10992
- if (key === "email" || key === "emailAddress" || key === "address" || key === "to" || key === "cc" || key === "bcc" || key === "recipient" || key === "attendees") {
10993
- collectEmails(item, out);
10994
- } else if (typeof item === "object") {
10995
- collectEmails(item, out);
10996
- }
10997
- }
10998
- }
10999
- }
11000
- function emails2(request) {
11001
- const out = /* @__PURE__ */ new Set();
11002
- collectEmails(request.params, out);
11003
- collectEmails(request.body, out);
11004
- collectEmails(request.input, out);
11005
- return [...out];
11006
- }
11007
11512
  function helperPaths(request, keys) {
11008
11513
  const input = request.input;
11009
11514
  if (!input || typeof input !== "object") return [];
@@ -11021,13 +11526,19 @@ function publicSharing(request) {
11021
11526
  const haystack = JSON.stringify({ params: request.params ?? {}, body: request.body ?? {}, input: request.input ?? {} }).toLowerCase();
11022
11527
  return /"role"\s*:\s*"(anyone|public)"/.test(haystack) || /"type"\s*:\s*"(anyone|public)"/.test(haystack);
11023
11528
  }
11024
- function domain(email2) {
11025
- return email2.slice(email2.lastIndexOf("@") + 1);
11529
+ function limitsRecipients(rule) {
11530
+ return rule.allowedRecipientDomains !== void 0 || rule.allowedRecipients !== void 0 || rule.deniedRecipientDomains !== void 0 || rule.allowExternalRecipients === false;
11531
+ }
11532
+ function matching(config2, entry, category, request) {
11533
+ return config2.policy.rules.filter((rule) => matches2(rule, category, request.account, entry.service, entry.id));
11534
+ }
11535
+ function needsAccountDomains(config2, entry, category, request) {
11536
+ return matching(config2, entry, category, request).some((rule) => rule.allowExternalRecipients === false);
11026
11537
  }
11027
11538
  function constrained(rule) {
11028
11539
  return rule.allowedRecipientDomains !== void 0 || rule.allowedRecipients !== void 0 || rule.deniedRecipientDomains !== void 0 || rule.allowExternalRecipients !== void 0 || rule.allowPublicSharing !== void 0 || rule.allowedReadRoots !== void 0 || rule.allowedWriteRoots !== void 0 || rule.maxPages !== void 0 || rule.maxItems !== void 0;
11029
11540
  }
11030
- function evaluatePolicy(config2, entry, category, request) {
11541
+ function evaluatePolicy(config2, entry, category, request, facts) {
11031
11542
  const constraints = {
11032
11543
  maxPages: config2.execution.maxPages,
11033
11544
  maxItems: config2.execution.maxItems,
@@ -11037,31 +11548,21 @@ function evaluatePolicy(config2, entry, category, request) {
11037
11548
  writeRoots: config2.execution.allowedWriteRoots.length > 0 ? [config2.execution.allowedWriteRoots] : []
11038
11549
  };
11039
11550
  const violations = [];
11040
- if (entry.category === "deny" || entry.schemaError) {
11041
- return { effect: "deny", violations: ["manifest-deny"], constraints };
11042
- }
11043
- if (!config2.accounts[request.account]) {
11044
- return { effect: "deny", violations: ["account-unknown"], constraints };
11045
- }
11551
+ const refuse = (violation) => ({ effect: "deny", violations: [violation], constraints, code: "POLICY_DENIED" });
11552
+ if (entry.category === "deny" || entry.schemaError) return refuse("manifest-deny");
11553
+ if (!config2.accounts[request.account]) return refuse("account-unknown");
11046
11554
  const profile = config2.accounts[request.account];
11047
11555
  const service = entry.service;
11048
- if (!profile.enabledServices.includes(service)) {
11049
- return { effect: "deny", violations: ["service-disabled"], constraints };
11050
- }
11051
- if (category === "deploy" || category === "watch" || category === "deny") {
11052
- return { effect: "deny", violations: ["never-committable"], constraints };
11053
- }
11556
+ if (!profile.enabledServices.includes(service)) return refuse("service-disabled");
11557
+ if (category === "deploy" || category === "watch" || category === "deny") return refuse("never-committable");
11054
11558
  if (category === "admin") {
11055
- if (!config2.admin.enabled) {
11056
- return { effect: "deny", violations: ["admin-disabled"], constraints };
11057
- }
11058
- if (!config2.admin.allowedServices.includes(service)) {
11059
- return { effect: "deny", violations: ["admin-service-not-allowed"], constraints };
11060
- }
11559
+ if (!config2.admin.enabled) return refuse("admin-disabled");
11560
+ if (!config2.admin.allowedServices.includes(service)) return refuse("admin-service-not-allowed");
11061
11561
  }
11062
11562
  let denied2 = false;
11063
11563
  let ruleId;
11064
- const recipients = emails2(request);
11564
+ let unknownRecipients = 0;
11565
+ let unknownDomain = 0;
11065
11566
  const readPaths = [
11066
11567
  ...request.upload?.path ? [request.upload.path] : [],
11067
11568
  ...helperPaths(request, ["path", "file", "filePath", "source", "attachments"])
@@ -11070,28 +11571,44 @@ function evaluatePolicy(config2, entry, category, request) {
11070
11571
  ...request.download?.path ? [request.download.path] : [],
11071
11572
  ...helperPaths(request, ["destination", "output", "outPath"])
11072
11573
  ];
11073
- const external = recipients.filter((email2) => domain(email2) !== domain(recipients[0] ?? ""));
11074
- const isPublic = publicSharing(request);
11075
- for (const rule of config2.policy.rules) {
11076
- if (!matches2(rule, category, request.account, service, entry.id)) continue;
11574
+ const recipients = facts === "pending" ? void 0 : facts.recipients;
11575
+ const targets = recipients ? [
11576
+ ...recipients.addresses.map((address) => ({ label: address, address, domain: domainOf(address) })),
11577
+ ...recipients.domains.map((domain) => ({ label: `*@${domain}`, address: void 0, domain }))
11578
+ ] : [];
11579
+ const own2 = facts === "pending" || !facts.accountDomains ? void 0 : new Set(facts.accountDomains.map((value) => value.toLowerCase()));
11580
+ const isPublic = publicSharing(request) || recipients?.public === true;
11581
+ for (const rule of matching(config2, entry, category, request)) {
11077
11582
  const before = violations.length;
11078
- if (rule.allowedRecipientDomains && recipients.length > 0) {
11079
- const allowed = new Set(rule.allowedRecipientDomains.map((value) => value.toLowerCase()));
11080
- const bad = recipients.filter((email2) => !allowed.has(domain(email2)));
11081
- if (bad.length > 0) violations.push(`recipients outside allowed domains: ${bad.join(", ")}`);
11082
- }
11083
- if (rule.allowedRecipients && recipients.length > 0) {
11084
- const allowed = new Set(rule.allowedRecipients.map((value) => value.trim().toLowerCase()));
11085
- const bad = recipients.filter((email2) => !allowed.has(email2));
11086
- if (bad.length > 0) violations.push(`recipients not in allowedRecipients: ${bad.join(", ")}`);
11087
- }
11088
- if (rule.deniedRecipientDomains) {
11089
- const denied3 = new Set(rule.deniedRecipientDomains.map((value) => value.toLowerCase()));
11090
- const bad = recipients.filter((email2) => denied3.has(domain(email2)));
11091
- if (bad.length > 0) violations.push(`recipients in denied domains: ${bad.join(", ")}`);
11092
- }
11093
- if (rule.allowExternalRecipients === false && external.length > 0) {
11094
- violations.push(`external recipients are disabled: ${external.join(", ")}`);
11583
+ if (recipients && limitsRecipients(rule)) {
11584
+ if (recipients.unknown) {
11585
+ violations.push(`rule ${rule.id} limits recipients, but they could not be determined: ${recipients.unknown}`);
11586
+ unknownRecipients++;
11587
+ }
11588
+ if (rule.allowedRecipientDomains) {
11589
+ const allowed = new Set(rule.allowedRecipientDomains.map((value) => value.toLowerCase()));
11590
+ const bad = targets.filter((target) => !allowed.has(target.domain));
11591
+ if (bad.length > 0) violations.push(`recipients outside allowed domains: ${bad.map((target) => target.label).join(", ")}`);
11592
+ }
11593
+ if (rule.allowedRecipients) {
11594
+ const allowed = new Set(rule.allowedRecipients.map((value) => value.trim().toLowerCase()));
11595
+ const bad = targets.filter((target) => target.address === void 0 || !allowed.has(target.address));
11596
+ if (bad.length > 0) violations.push(`recipients not in allowedRecipients: ${bad.map((target) => target.label).join(", ")}`);
11597
+ }
11598
+ if (rule.deniedRecipientDomains) {
11599
+ const deniedDomains = new Set(rule.deniedRecipientDomains.map((value) => value.toLowerCase()));
11600
+ const bad = targets.filter((target) => deniedDomains.has(target.domain));
11601
+ if (bad.length > 0) violations.push(`recipients in denied domains: ${bad.map((target) => target.label).join(", ")}`);
11602
+ }
11603
+ if (rule.allowExternalRecipients === false && (targets.length > 0 || recipients.unknown)) {
11604
+ if (!own2) {
11605
+ violations.push(`rule ${rule.id} disables external recipients, but the account's own domain is unknown`);
11606
+ unknownDomain++;
11607
+ } else {
11608
+ const external = targets.filter((target) => !own2.has(target.domain));
11609
+ if (external.length > 0) violations.push(`external recipients are disabled: ${external.map((target) => target.label).join(", ")}`);
11610
+ }
11611
+ }
11095
11612
  }
11096
11613
  if (rule.allowPublicSharing === false && isPublic) {
11097
11614
  violations.push("public sharing is disabled");
@@ -11129,16 +11646,18 @@ function evaluatePolicy(config2, entry, category, request) {
11129
11646
  violations.push("admin plans require a non-empty reason");
11130
11647
  }
11131
11648
  if (violations.length > 0 || denied2) {
11132
- return { effect: "deny", ruleId, violations: violations.length > 0 ? violations : [`denied by rule ${ruleId ?? "policy"}`], constraints };
11649
+ const definite = violations.length - unknownRecipients - unknownDomain;
11650
+ const code2 = definite > 0 || violations.length === 0 ? "POLICY_DENIED" : unknownRecipients > 0 ? "RECIPIENTS_UNKNOWN" : "ACCOUNT_DOMAIN_UNKNOWN";
11651
+ return { effect: "deny", ruleId, violations: violations.length > 0 ? violations : [`denied by rule ${ruleId ?? "policy"}`], constraints, code: code2 };
11133
11652
  }
11134
- return { effect: "allow", ruleId, violations, constraints };
11653
+ return { effect: "allow", ruleId, violations, constraints, code: "POLICY_DENIED" };
11135
11654
  }
11136
11655
 
11137
11656
  // src/process/staging.ts
11138
11657
  import { createHash as createHash3 } from "node:crypto";
11139
11658
  import { constants } from "node:fs";
11140
- import { link, lstat, mkdir as mkdir2, mkdtemp, open as open2, realpath, rename, rm, stat, unlink, writeFile as writeFile2 } from "node:fs/promises";
11141
- import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute7, join as join7 } from "node:path";
11659
+ import { link, lstat, mkdir as mkdir2, mkdtemp as mkdtemp2, open as open2, realpath, rename as rename2, rm as rm2, stat as stat2, unlink, writeFile as writeFile2 } from "node:fs/promises";
11660
+ import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute7, join as join8 } from "node:path";
11142
11661
  var CHUNK = 64 * 1024;
11143
11662
  var READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
11144
11663
  var Stage = class _Stage {
@@ -11150,13 +11669,13 @@ var Stage = class _Stage {
11150
11669
  signal;
11151
11670
  static async create(workDir, signal) {
11152
11671
  throwIfCancelled(signal);
11153
- const dir = await mkdtemp(join7(workDir, "call-"));
11672
+ const dir = await mkdtemp2(join8(workDir, "call-"));
11154
11673
  try {
11155
11674
  throwIfCancelled(signal);
11156
- await writeFile2(join7(dir, ".env"), "", { mode: 384 });
11675
+ await writeFile2(join8(dir, ".env"), "", { mode: 384 });
11157
11676
  return new _Stage(dir, signal);
11158
11677
  } catch (error2) {
11159
- await rm(dir, { recursive: true, force: true }).catch(() => void 0);
11678
+ await rm2(dir, { recursive: true, force: true }).catch(() => void 0);
11160
11679
  throw error2;
11161
11680
  }
11162
11681
  }
@@ -11170,7 +11689,7 @@ var Stage = class _Stage {
11170
11689
  /** Create the directory gws writes the output into. */
11171
11690
  async prepareOutput(output) {
11172
11691
  throwIfCancelled(this.signal);
11173
- await mkdir2(dirname5(join7(this.dir, output.rel)), { recursive: true, mode: 448 });
11692
+ await mkdir2(dirname5(join8(this.dir, output.rel)), { recursive: true, mode: 448 });
11174
11693
  }
11175
11694
  /**
11176
11695
  * Move the staged output to its destination: size cap, then the same
@@ -11180,7 +11699,7 @@ var Stage = class _Stage {
11180
11699
  */
11181
11700
  async deliver(output, limits) {
11182
11701
  throwIfCancelled(this.signal);
11183
- const staged = join7(this.dir, output.rel);
11702
+ const staged = join8(this.dir, output.rel);
11184
11703
  const info = await lstat(staged).catch(() => void 0);
11185
11704
  if (!info?.isFile()) throw new GwsError("DOWNLOAD_INVALID", "gws did not write the output file", { next: "Report it; the plan is used up." });
11186
11705
  if (info.size > limits.maxDownloadBytes) {
@@ -11192,7 +11711,7 @@ var Stage = class _Stage {
11192
11711
  return { path: output.destination, bytes: info.size, sha256, ...cleanupDegraded ? { cleanupDegraded: true } : {} };
11193
11712
  }
11194
11713
  async dispose() {
11195
- await rm(this.dir, { recursive: true, force: true });
11714
+ await rm2(this.dir, { recursive: true, force: true });
11196
11715
  }
11197
11716
  async stageInput(input, maxBytes, roots) {
11198
11717
  throwIfCancelled(this.signal);
@@ -11204,7 +11723,7 @@ var Stage = class _Stage {
11204
11723
  next: "Use a file inside execution.allowedReadRoots, or ask the user to add its folder there."
11205
11724
  });
11206
11725
  }
11207
- if (!(await stat(real)).isFile()) throw new GwsError("UPLOAD_INVALID", `'${input.source}' is not a regular file`);
11726
+ if (!(await stat2(real)).isFile()) throw new GwsError("UPLOAD_INVALID", `'${input.source}' is not a regular file`);
11208
11727
  let source;
11209
11728
  let target;
11210
11729
  try {
@@ -11212,7 +11731,7 @@ var Stage = class _Stage {
11212
11731
  const info = await source.stat();
11213
11732
  if (!info.isFile()) throw new GwsError("UPLOAD_INVALID", `'${input.source}' is not a regular file`);
11214
11733
  if (info.size > maxBytes) throw uploadLimit(input.source, info.size, maxBytes);
11215
- const destination = join7(this.dir, input.rel);
11734
+ const destination = join8(this.dir, input.rel);
11216
11735
  throwIfCancelled(this.signal);
11217
11736
  await mkdir2(dirname5(destination), { recursive: true, mode: 448 });
11218
11737
  throwIfCancelled(this.signal);
@@ -11242,7 +11761,7 @@ async function checkDestination(output, limits) {
11242
11761
  if (!isAbsolute7(output.destination)) throw new GwsError("DOWNLOAD_INVALID", `'${output.destination}' must be an absolute path`);
11243
11762
  const parent = await realpath(dirname5(output.destination)).catch(() => void 0);
11244
11763
  if (!parent) throw new GwsError("DOWNLOAD_INVALID", `the directory for '${output.destination}' does not exist`);
11245
- const target = join7(parent, basename2(output.destination));
11764
+ const target = join8(parent, basename2(output.destination));
11246
11765
  if (!withinRootSets(target, await realRootSets(limits.writeRoots))) {
11247
11766
  throw new GwsError("POLICY_DENIED", `download path '${output.destination}' resolves outside allowed write roots`, {
11248
11767
  next: "Use a destination inside execution.allowedWriteRoots, or ask the user to add its folder there."
@@ -11256,20 +11775,20 @@ async function checkDestination(output, limits) {
11256
11775
  async function place(staged, target, output, signal) {
11257
11776
  throwIfCancelled(signal);
11258
11777
  try {
11259
- if (output.overwrite) await rename(staged, target);
11778
+ if (output.overwrite) await rename2(staged, target);
11260
11779
  else await link(staged, target);
11261
11780
  return false;
11262
11781
  } catch (error2) {
11263
11782
  if (code(error2) === "EEXIST") throw exists(output.destination);
11264
11783
  if (code(error2) !== "EXDEV" && code(error2) !== "EPERM" && code(error2) !== "ENOTSUP") throw error2;
11265
11784
  }
11266
- const temp = join7(dirname5(target), `.${basename2(target)}.${process.pid}.${Date.now()}.partial`);
11785
+ const temp = join8(dirname5(target), `.${basename2(target)}.${process.pid}.${Date.now()}.partial`);
11267
11786
  let cleanupDegraded = false;
11268
11787
  try {
11269
11788
  throwIfCancelled(signal);
11270
11789
  await copyDurable(staged, temp, signal);
11271
11790
  throwIfCancelled(signal);
11272
- if (output.overwrite) await rename(temp, target);
11791
+ if (output.overwrite) await rename2(temp, target);
11273
11792
  else {
11274
11793
  try {
11275
11794
  await link(temp, target);
@@ -11338,6 +11857,17 @@ function code(error2) {
11338
11857
  // src/runtime/operations.ts
11339
11858
  var METHOD_FIELDS = ["params", "body", "fields", "pagination", "upload", "download"];
11340
11859
  var PLAN_INSTEAD = "Call gws_plan with the same request, then commit with the tool named by the plan's category.";
11860
+ var SAVE_INSTEAD = "Google answered with a body that is not JSON. To save it, plan the same request with download.path, then commit it with gws_export.";
11861
+ var ADDRESS_TTL_MS = 10 * 60 * 1e3;
11862
+ function policyError(decision, account2) {
11863
+ const detail = decision.violations.join("; ");
11864
+ if (decision.code === "ACCOUNT_DOMAIN_UNKNOWN") {
11865
+ return new GwsError("ACCOUNT_DOMAIN_UNKNOWN", detail, {
11866
+ next: `Ask the user to set accounts.${account2}.domains to the account's email domains, or to enable Gmail, Drive or Calendar for '${account2}' so the plugin can read its address.`
11867
+ });
11868
+ }
11869
+ return new GwsError(decision.code, detail);
11870
+ }
11341
11871
  var Operations = class {
11342
11872
  constructor(runtime, accounts) {
11343
11873
  this.runtime = runtime;
@@ -11345,6 +11875,8 @@ var Operations = class {
11345
11875
  }
11346
11876
  runtime;
11347
11877
  accounts;
11878
+ /** Each account's own address, once read. */
11879
+ addresses = /* @__PURE__ */ new Map();
11348
11880
  /** The configured account a call names, or the only one when it names none. */
11349
11881
  account(alias) {
11350
11882
  const aliases = Object.keys(this.runtime.config.accounts);
@@ -11416,8 +11948,9 @@ var Operations = class {
11416
11948
  throw new GwsError("POLICY_DENIED", `'${entry.id}' is ${category2}; use gws_plan and the matching commit tool`, { next: PLAN_INSTEAD });
11417
11949
  }
11418
11950
  }
11419
- const decision2 = evaluatePolicy(this.runtime.config, entry, "read", request);
11420
- if (decision2.effect === "deny") throw new GwsError("POLICY_DENIED", decision2.violations.join("; "));
11951
+ const facts = { recipients: recipientPlan(entry.id, "read", request).known, accountDomains: this.runtime.config.accounts[request.account]?.domains };
11952
+ const decision2 = evaluatePolicy(this.runtime.config, entry, "read", request, facts);
11953
+ if (decision2.effect === "deny") throw policyError(decision2, request.account);
11421
11954
  return { category: "read", decision: decision2 };
11422
11955
  }
11423
11956
  const category = helper ? helper.category : effectiveCategory(entry, request);
@@ -11426,8 +11959,8 @@ var Operations = class {
11426
11959
  next: category === "read" ? "Run it with gws_read." : "Tell the user the plugin never runs it."
11427
11960
  });
11428
11961
  }
11429
- const decision = evaluatePolicy(this.runtime.config, entry, category, request);
11430
- if (decision.effect === "deny") throw new GwsError("POLICY_DENIED", decision.violations.join("; "));
11962
+ const decision = evaluatePolicy(this.runtime.config, entry, category, request, "pending");
11963
+ if (decision.effect === "deny") throw policyError(decision, request.account);
11431
11964
  if (category === "admin" && (!this.runtime.config.admin.enabled || decision.effect !== "allow")) {
11432
11965
  throw new GwsError("POLICY_DENIED", "admin operations require admin.enabled, an allowed service, and a reason when one is required");
11433
11966
  }
@@ -11440,11 +11973,13 @@ var Operations = class {
11440
11973
  inspect(input, mode) {
11441
11974
  const resolved = this.resolve(input);
11442
11975
  const { category } = this.gate(resolved, mode);
11976
+ const reads = mode === "plan" ? recipientPlan(resolved.entry.id, category, resolved.request).lookups.map((lookup) => lookup.operation) : [];
11443
11977
  return {
11444
11978
  account: resolved.request.account,
11445
11979
  operation: resolved.entry.id,
11446
11980
  service: resolved.entry.service,
11447
- category
11981
+ category,
11982
+ ...reads.length > 0 ? { reads } : {}
11448
11983
  };
11449
11984
  }
11450
11985
  /**
@@ -11500,7 +12035,7 @@ var Operations = class {
11500
12035
  requestedInlineBytes: helper ? void 0 : request.response?.maxInlineBytes
11501
12036
  });
11502
12037
  const failed = helper ? result.exitCode !== 0 || isApiError(parsed.data) : !parsed.ok;
11503
- if (failed) throw processError(result, parsed, this.accounts.remediation(request.account, entry.scopes, entry.service));
12038
+ if (failed) throw processError(result, parsed, entry.service, this.accounts.remediation(request.account, entry.scopes, entry.service));
11504
12039
  const pages = paged ? capPageItems(parsed.data, itemCap(request, limits)) : void 0;
11505
12040
  const cap = inlineCap(this.runtime.config.execution.maxInlineBytes, request.response?.mode, request.response?.maxInlineBytes);
11506
12041
  const truncated = pages ? Buffer.byteLength(JSON.stringify(pages.data)) > cap : parsed.truncated;
@@ -11517,7 +12052,7 @@ var Operations = class {
11517
12052
  ok: true,
11518
12053
  operation: entry.id,
11519
12054
  category: "read",
11520
- data: helper ? parsed.data ?? parsed.raw ?? result.stdout.toString("utf8") : pages ? pages.data : parsed.data,
12055
+ data: helper ? parsed.data ?? parsed.raw ?? result.stdout.toString("utf8") : pages ? pages.data : withoutSavedBody(parsed.data, SAVE_INSTEAD),
11521
12056
  truncated,
11522
12057
  diagnostics: parsed.diagnostics,
11523
12058
  pagination: pages ? { pages: pages.pages, items: pages.items, capped: pages.capped } : void 0,
@@ -11532,8 +12067,12 @@ var Operations = class {
11532
12067
  const resolved = this.resolve(input);
11533
12068
  const { entry, helper, request } = resolved;
11534
12069
  const started = Date.now();
11535
- const { category, decision } = this.gate(resolved, "plan");
12070
+ const { category } = this.gate(resolved, "plan");
11536
12071
  const commitCategory = category;
12072
+ const recipientsPlan = recipientPlan(entry.id, category, request);
12073
+ const facts = await this.policyFacts(resolved, category, recipientsPlan, ctx);
12074
+ const decision = evaluatePolicy(this.runtime.config, entry, category, request, facts);
12075
+ if (decision.effect === "deny") throw policyError(decision, request.account);
11537
12076
  const limits = fileLimits(decision);
11538
12077
  let dryRunArgv;
11539
12078
  let commitArgv;
@@ -11564,14 +12103,16 @@ var Operations = class {
11564
12103
  const dry = await this.accounts.run(dryRunArgv, request.account, entry.id, ctx, { scopes: entry.scopes, dryRun: true, cwd: stage?.dir });
11565
12104
  report2(ctx, { status: "parsing", operation: entry.id });
11566
12105
  parsed = parseResult(dry, { maxInlineBytes: this.runtime.config.execution.maxInlineBytes });
11567
- if (dry.exitCode !== 0 || !parsed.ok) throw processError(dry, parsed, this.accounts.remediation(request.account, entry.scopes, entry.service));
12106
+ if (dry.exitCode !== 0 || !parsed.ok) throw processError(dry, parsed, entry.service, this.accounts.remediation(request.account, entry.scopes, entry.service));
11568
12107
  } finally {
11569
12108
  await stage?.dispose();
11570
12109
  }
11571
12110
  throwIfCancelled(ctx.signal);
11572
12111
  const reason = request.reason?.trim();
11573
- const planPreview = reason ? { ...preview, reason } : preview;
11574
- const plan = this.runtime.plans.create({
12112
+ const { recipients } = facts;
12113
+ const reached = category === "send" || category === "share" ? { ...preview, recipients: recipientLabels(recipients), ...recipients.unknown ? { recipientsUnknown: recipients.unknown } : {} } : preview;
12114
+ const planPreview = reason ? { ...reached, reason } : reached;
12115
+ const plan2 = this.runtime.plans.create({
11575
12116
  account: request.account,
11576
12117
  sessionId: ctx.sessionId,
11577
12118
  agentId: ctx.agentId,
@@ -11581,6 +12122,8 @@ var Operations = class {
11581
12122
  argv: commitArgv,
11582
12123
  preview: planPreview,
11583
12124
  files: planned,
12125
+ // Recipients read at plan time can change before the commit (a draft, an event's guests): the commit reads them again.
12126
+ ...recipientsPlan.lookups.length > 0 ? { recheck: { request, recipients: facts.recipients.addresses.concat(facts.recipients.domains) } } : {},
11584
12127
  ttlSeconds: this.runtime.config.policy.planTtlSeconds
11585
12128
  });
11586
12129
  this.runtime.audit.emit({
@@ -11594,8 +12137,8 @@ var Operations = class {
11594
12137
  });
11595
12138
  return {
11596
12139
  ok: true,
11597
- planId: plan.id,
11598
- expiresAt: new Date(plan.expiresAt).toISOString(),
12140
+ planId: plan2.id,
12141
+ expiresAt: new Date(plan2.expiresAt).toISOString(),
11599
12142
  category: commitCategory,
11600
12143
  preview: planPreview,
11601
12144
  dryRun: redactDryRun(parsed.data)
@@ -11606,7 +12149,7 @@ var Operations = class {
11606
12149
  throwIfCancelled(ctx.signal);
11607
12150
  if (existing.files?.output) await checkDestination(existing.files.output, existing.files.limits);
11608
12151
  this.runtime.assertActive(ctx.signal);
11609
- const plan = this.runtime.plans.acquire(input.planId, {
12152
+ const plan2 = this.runtime.plans.acquire(input.planId, {
11610
12153
  sessionId: ctx.sessionId,
11611
12154
  agentId: ctx.agentId,
11612
12155
  account: existing.account,
@@ -11616,39 +12159,41 @@ var Operations = class {
11616
12159
  let stage;
11617
12160
  let receipt;
11618
12161
  try {
11619
- if (plan.files) stage = await Stage.create(this.runtime.workDir, ctx.signal);
11620
- if (stage && plan.files) {
11621
- const staged = await stage.stageInputs(plan.files.inputs, plan.files.limits);
11622
- refuseChanged(plan.files.inputs, staged);
11623
- if (plan.files.output) await stage.prepareOutput(plan.files.output);
11624
- }
11625
- const method = commandIndex().methods.get(plan.operation);
12162
+ if (plan2.recheck) await this.recheck(plan2, ctx);
12163
+ if (plan2.files) stage = await Stage.create(this.runtime.workDir, ctx.signal);
12164
+ if (stage && plan2.files) {
12165
+ const staged = await stage.stageInputs(plan2.files.inputs, plan2.files.limits);
12166
+ refuseChanged(plan2.files.inputs, staged);
12167
+ if (plan2.files.output) await stage.prepareOutput(plan2.files.output);
12168
+ }
12169
+ const method = commandIndex().methods.get(plan2.operation);
11626
12170
  const scopes = method?.scopes;
11627
- const result = await this.accounts.run(plan.argv, plan.account, plan.operation, ctx, { scopes, cwd: stage?.dir });
11628
- report2(ctx, { status: "parsing", operation: plan.operation });
12171
+ const result = await this.accounts.run(plan2.argv, plan2.account, plan2.operation, ctx, { scopes, cwd: stage?.dir });
12172
+ report2(ctx, { status: "parsing", operation: plan2.operation });
11629
12173
  const parsed = parseResult(result, { maxInlineBytes: this.runtime.config.execution.maxInlineBytes });
11630
- if (!parsed.ok) throw processError(result, parsed, this.accounts.remediation(plan.account, scopes, method?.service));
11631
- const delivered = stage && plan.files?.output ? await stage.deliver(plan.files.output, plan.files.limits) : void 0;
12174
+ if (!parsed.ok) throw processError(result, parsed, plan2.service, this.accounts.remediation(plan2.account, scopes, plan2.service));
12175
+ const delivered = stage && plan2.files?.output ? await stage.deliver(plan2.files.output, plan2.files.limits) : void 0;
11632
12176
  const file = delivered ? { path: delivered.path, bytes: delivered.bytes, sha256: delivered.sha256 } : void 0;
11633
- this.runtime.plans.complete(plan.id);
12177
+ this.runtime.plans.complete(plan2.id);
11634
12178
  this.runtime.audit.emit({
11635
12179
  event: "operation.commit",
11636
12180
  outcome: "success",
11637
- account: plan.account,
12181
+ account: plan2.account,
11638
12182
  category,
11639
- operation: plan.operation,
12183
+ operation: plan2.operation,
11640
12184
  durationMs: Date.now() - started
11641
12185
  });
11642
- receipt = { ok: true, planId: plan.id, category, operation: plan.operation, result: parsed.data ?? parsed.raw, file, ...delivered?.cleanupDegraded ? { cleanupDegraded: true } : {} };
12186
+ const answer = plan2.files?.output ? parsed.data : withoutSavedBody(parsed.data);
12187
+ receipt = { ok: true, planId: plan2.id, category, operation: plan2.operation, result: answer ?? parsed.raw, file, ...delivered?.cleanupDegraded ? { cleanupDegraded: true } : {} };
11643
12188
  return receipt;
11644
12189
  } catch (error2) {
11645
- this.runtime.plans.release(plan.id);
12190
+ this.runtime.plans.release(plan2.id);
11646
12191
  this.runtime.audit.emit({
11647
12192
  event: "operation.commit",
11648
12193
  outcome: "error",
11649
- account: plan.account,
12194
+ account: plan2.account,
11650
12195
  category,
11651
- operation: plan.operation,
12196
+ operation: plan2.operation,
11652
12197
  durationMs: Date.now() - started,
11653
12198
  errorCode: auditErrorCode(error2)
11654
12199
  });
@@ -11668,6 +12213,85 @@ var Operations = class {
11668
12213
  }
11669
12214
  }
11670
12215
  }
12216
+ /**
12217
+ * The recipient facts for a plan: what the request carries, the reads
12218
+ * that find the rest, and the account's own domains when a rule needs them.
12219
+ * A read that fails leaves the recipients unknown, which a recipient rule refuses.
12220
+ */
12221
+ async policyFacts(resolved, category, plan2, ctx) {
12222
+ const { entry, request } = resolved;
12223
+ let recipients = plan2.known;
12224
+ if (plan2.lookups.length > 0) {
12225
+ const self = plan2.needsSelf ? await this.ownAddress(request.account, entry.service, ctx) : void 0;
12226
+ for (const lookup of plan2.lookups) recipients = withLookup(recipients, await this.lookup(lookup, request.account, ctx, self), lookup);
12227
+ }
12228
+ const configured = this.runtime.config.accounts[request.account]?.domains;
12229
+ const reaches = recipients.addresses.length > 0 || recipients.domains.length > 0 || recipients.unknown !== void 0;
12230
+ if (configured || !reaches || !needsAccountDomains(this.runtime.config, entry, category, request)) return { recipients, accountDomains: configured };
12231
+ const address = await this.ownAddress(request.account, entry.service, ctx);
12232
+ return { recipients, accountDomains: address ? [domainOf(address)] : void 0 };
12233
+ }
12234
+ /** Read the recipients again before a commit whose plan read them, and refuse when the policy now denies or someone was added. */
12235
+ async recheck(plan2, ctx) {
12236
+ const recheck = plan2.recheck;
12237
+ const resolved = this.resolve(recheck.request);
12238
+ const category = plan2.category;
12239
+ const facts = await this.policyFacts(resolved, category, recipientPlan(resolved.entry.id, category, resolved.request), ctx);
12240
+ const decision = evaluatePolicy(this.runtime.config, resolved.entry, category, resolved.request, facts);
12241
+ if (decision.effect === "deny") throw policyError(decision, plan2.account);
12242
+ const planned = new Set(recheck.recipients);
12243
+ const added = [...facts.recipients.addresses, ...facts.recipients.domains].filter((recipient) => !planned.has(recipient));
12244
+ if (added.length > 0) {
12245
+ throw new GwsError("PLAN_EXPIRED", `the recipients changed after the plan was created (added: ${added.join(", ")})`, { next: "Plan again and show the new preview." });
12246
+ }
12247
+ }
12248
+ /** One recipient read; undefined when it fails or answers in an unexpected shape. */
12249
+ async lookup(lookup, account2, ctx, self) {
12250
+ try {
12251
+ return lookup.extract(await this.readJson(lookup.operation, lookup.params, account2, ctx), self);
12252
+ } catch (error2) {
12253
+ throwIfCancelled(ctx.signal);
12254
+ this.runtime.assertActive(ctx.signal);
12255
+ if (GwsError.from(error2) === void 0) throw error2;
12256
+ return void 0;
12257
+ }
12258
+ }
12259
+ /** The account's own address, read with the operation's service first; undefined when no read gives it. */
12260
+ async ownAddress(account2, service, ctx) {
12261
+ const cached3 = this.addresses.get(account2);
12262
+ if (cached3 && Date.now() - cached3.at < ADDRESS_TTL_MS) return cached3.address;
12263
+ const enabled = this.runtime.config.accounts[account2]?.enabledServices ?? [];
12264
+ const services = [.../* @__PURE__ */ new Set([service, ...Object.keys(ACCOUNT_ADDRESS_READS)])].filter((name) => Object.hasOwn(ACCOUNT_ADDRESS_READS, name) && enabled.includes(name));
12265
+ for (const name of services) {
12266
+ const read = ACCOUNT_ADDRESS_READS[name];
12267
+ try {
12268
+ const address = read.extract(await this.readJson(read.operation, read.params, account2, ctx));
12269
+ if (address) {
12270
+ this.addresses.set(account2, { address, at: Date.now() });
12271
+ return address;
12272
+ }
12273
+ } catch (error2) {
12274
+ throwIfCancelled(ctx.signal);
12275
+ this.runtime.assertActive(ctx.signal);
12276
+ if (GwsError.from(error2) === void 0) throw error2;
12277
+ }
12278
+ }
12279
+ return void 0;
12280
+ }
12281
+ /** A field-masked read the plugin makes for itself, under the read policy; its parsed JSON. */
12282
+ async readJson(operation, params, account2, ctx) {
12283
+ const entry = commandIndex().methods.get(operation);
12284
+ if (!entry) throw new GwsError("OPERATION_UNKNOWN", `'${operation}' is not in the gws manifest`);
12285
+ const request = { account: account2, operation, params };
12286
+ const decision = evaluatePolicy(this.runtime.config, entry, "read", request, { recipients: recipientPlan(operation, "read", request).known });
12287
+ if (decision.effect === "deny") throw policyError(decision, account2);
12288
+ const argv = buildCommand(entry, request, this.runtime.config, { dryRun: false, category: "read" }).argv;
12289
+ const result = await this.accounts.run(argv, account2, operation, ctx, { scopes: entry.scopes });
12290
+ this.runtime.assertActive(ctx.signal);
12291
+ const parsed = parseResult(result, { maxInlineBytes: this.runtime.config.execution.maxInlineBytes });
12292
+ if (!parsed.ok) throw processError(result, parsed, entry.service, this.accounts.remediation(account2, entry.scopes, entry.service));
12293
+ return parsed.data;
12294
+ }
11671
12295
  };
11672
12296
  function fileLimits(decision) {
11673
12297
  const { maxUploadBytes, maxDownloadBytes, readRoots, writeRoots } = decision.constraints;
@@ -11702,10 +12326,9 @@ var Runtime = class _Runtime {
11702
12326
  this.version = version2;
11703
12327
  this.binary = binary;
11704
12328
  this.paths = options.paths ?? pluginPaths();
11705
- this.workDir = mkdtempSync(join8(tmpdir(), "opencode-gws-"));
11706
- writeFileSync(join8(this.workDir, ".env"), "");
11707
- this.audit = new Audit({ ...config2.audit, path: config2.audit.path ?? join8(this.paths.stateDir, "audit.jsonl") }, options.pluginVersion);
11708
- this.schema = new SchemaClient(binary, this.workDir, localEnv(this.workDir, ownedDir(join8(this.paths.cacheDir, "discovery"))), config2.execution.schemaTimeoutMs);
12329
+ this.workDir = createWorkDir();
12330
+ this.audit = new Audit({ ...config2.audit, path: config2.audit.path ?? join9(this.paths.stateDir, "audit.jsonl") }, options.pluginVersion);
12331
+ this.schema = new SchemaClient(binary, this.workDir, localEnv(this.workDir, ownedDir(join9(this.paths.cacheDir, "discovery"))), config2.execution.schemaTimeoutMs);
11709
12332
  this.accounts = new Accounts(this);
11710
12333
  this.operations = new Operations(this, this.accounts);
11711
12334
  }
@@ -11743,7 +12366,8 @@ var Runtime = class _Runtime {
11743
12366
  }
11744
12367
  static async create(config2, options) {
11745
12368
  const binary = resolveBinary(config2, options.projectDirectory);
11746
- const probeDir = mkdtempSync(join8(tmpdir(), "opencode-gws-probe-"));
12369
+ removeStaleWorkDirs();
12370
+ const probeDir = mkdtempSync2(join9(tmpdir2(), "opencode-gws-probe-"));
11747
12371
  let version2 = "unknown";
11748
12372
  try {
11749
12373
  const probe = await runProcess({
@@ -11760,7 +12384,7 @@ var Runtime = class _Runtime {
11760
12384
  throw new Error(`GWS_VERSION_MISMATCH: Requires ${GWS_VERSION} and the matching manifest`);
11761
12385
  }
11762
12386
  } finally {
11763
- rmSync(probeDir, { recursive: true, force: true });
12387
+ rmSync2(probeDir, { recursive: true, force: true });
11764
12388
  }
11765
12389
  return new _Runtime(config2, options, binary, version2);
11766
12390
  }
@@ -11824,7 +12448,7 @@ var Runtime = class _Runtime {
11824
12448
  this.plans.invalidateAll();
11825
12449
  this.audit.close();
11826
12450
  this.closing = settled.then(() => {
11827
- rmSync(this.workDir, { recursive: true, force: true });
12451
+ rmSync2(this.workDir, { recursive: true, force: true });
11828
12452
  });
11829
12453
  return this.closing;
11830
12454
  }
@@ -11847,8 +12471,7 @@ async function start(ctx) {
11847
12471
  const ids = integrationIds(config2.accounts);
11848
12472
  const runtime = await Runtime.create(config2, {
11849
12473
  pluginVersion: PLUGIN_VERSION,
11850
- hostVersion: host.version,
11851
- hostWarning: host.warning,
12474
+ host,
11852
12475
  projectDirectory: ctx.location.directory,
11853
12476
  tokenProvider: integrationTokenProvider(ctx, config2, ids),
11854
12477
  connection: integrationConnection(ctx, ids),