workspaces 0.1.17 → 0.1.18

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f662b3baa05bd9cd6a684906e2bf2d75d663dfa493fd684ef1090adb0095276
4
- data.tar.gz: 2265a4ddeeae587c04b2eae5e8ed0e8a7f5f64988bb9c22c0f1f4c9202c30b9f
3
+ metadata.gz: 8a0a1c93490624ea0d8bb2fe77ddb8e7d1ad9bf53fe7700b6eec7245cc2e031a
4
+ data.tar.gz: 561ec874bbbe8d1c537066b612065c23db82caea34ca8d19cc894b66245be24d
5
5
  SHA512:
6
- metadata.gz: 3cea009527f192e61a8c14a2f9642e7f67d0d58118e2f13884a28c68d5b10c17ca12c98dfba24af32618ebae87e4f3f34645a10048ce110ec395424be1dda5c0
7
- data.tar.gz: 496afa3b947a7c21a58f9e66f9f542b3146ce327fabad2b5633bd58f63d089a3e8ad965420e251e0589213acffc1219845625c9ba8377d42d6b59e9a5c608d8e
6
+ metadata.gz: 7c9e26c91a93c37d8ea2391a0dd1c6ed3a163ba64c37deb2d73cae6d4688c33a7d5f9573ff98a8510789820d06e3f9af36dd78e86ba66b80a13435b11526ffd4
7
+ data.tar.gz: d0deac511f060614b66a41e793af6a4e3c7eb64e2150681dccd65c4910f0fffb5b74d213db865f38a3cb748adfc712bcfdf0a268328d4d9aea4d3375e23f2070
data/DESIGN.md CHANGED
@@ -62,6 +62,7 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
62
62
  - **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
63
63
  - **Per-step re-run controls**: inside each step's accordion, small subtle **Re-run this step** and **Re-run from here** forms aimed at `/workspaces/ID/resume?only=KEY` and `?from=KEY`. The server decides which appear (`presentation.resume.step_modes`), so the page never offers something that would be refused; they are hidden while a run is in progress and are disabled with the other controls while busy.
64
64
  - **Sleeping**: a workspace asleep is its own state (`sleeping`), distinct from stopped and failed. The lead reads "Your workspace is asleep", says files and data are kept and that opening the preview wakes it, and offers one **Wake workspace** button. A ready workspace shows one muted line, "Sleeps after N min without preview traffic", or "Kept awake" when exempt. **Keep awake** / **Allow sleep** are subtle workspace controls. A waking workspace is simply busy.
65
+ - **Environment rows**: the settings form is a list of rows, each a name input, a value input and a **Secret** checkbox, with **Add variable** and a per-row **Remove** (both revealed by script; without it the server-rendered rows still save). Rows post as `env[N][name|value|secret]`. A secret row's value is a password field that is never pre-filled; a stored secret shows its name, the placeholder "Stored. Enter a new value to replace it", and a locked checked checkbox (a hidden field carries it), so it can only be replaced or removed, not exposed. Ticking Secret on a new row switches its value to a password field. Presets replace only the visible rows and are matched against visible rows alone. A note under the rows shows why secrets are unavailable when the vault is not usable, and a line explains that secrets are write-only.
65
66
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, retry failed step, and refresh
66
67
  - **Open workspace**: success-styled link available when the server is ready
67
68
  - **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
data/README.md CHANGED
@@ -242,6 +242,61 @@ temporarily stashed and restored (including staged state). On a restore conflict
242
242
  the stash is retained for manual recovery. Updating the checkout does not run
243
243
  setup or restart the already-running backend; use Rebuild & restart afterward.
244
244
 
245
+ ### Environment variables and secrets
246
+
247
+ A workspace's settings are rows of **name** and **value**, with a **Secret**
248
+ checkbox on each. Add and remove rows as needed; **Save & restart** applies them.
249
+
250
+ A *secret* is an environment variable that is write-only. Its value goes to a
251
+ vault, is handed to the workspace's setup steps and server as an ordinary
252
+ environment variable, and is never shown again: not in the dashboard, the status
253
+ API, the logs (any output that contains it is filtered) or error messages. A saved
254
+ secret appears as its name with an empty, password-style field that says a value is
255
+ stored. Leave that field blank to keep the stored value, type a new one to replace
256
+ it, or remove the row to delete the secret. A stored secret cannot be turned back
257
+ into a visible variable. Names that look like secrets (`*_TOKEN`, `*_SECRET`,
258
+ `*_PASSWORD`, `*_API_KEY`, ...) cannot be saved as visible variables; tick **Secret**.
259
+ Presets only ever change visible variables.
260
+
261
+ Which vault is used is set where `serve` runs, with `WORKSPACES_VAULT` (default
262
+ `file`). The built-in `file` vault keeps one AES-256-GCM encrypted file per workspace
263
+ in `$WORKSPACES_HOME/vault/`, with a key derived per workspace from a master key you
264
+ provide:
265
+
266
+ ```sh
267
+ export WORKSPACES_MASTER_KEY="$(openssl rand -base64 32)" # keep this; it cannot be recovered
268
+ bin/workspaces serve --secure
269
+ ```
270
+
271
+ Without `WORKSPACES_MASTER_KEY` the settings page says secrets are turned off, and
272
+ everything else works as before. With the wrong key a run fails with a plain reason
273
+ rather than starting without its secrets, and saving is refused so stored secrets are
274
+ never overwritten. The master key is read only by the front door: it is removed from
275
+ the environment of every step, server and `exec` command.
276
+
277
+ Removing a workspace (`remove`, or the dashboard's Remove button) deletes its secrets
278
+ too. The file vault is always asked to delete its own files, even if `WORKSPACES_VAULT`
279
+ has since been changed. A secret that cannot be deleted is reported rather than hidden:
280
+ `remove` exits non-zero and lists it under `secrets_left`, and the dashboard says the
281
+ workspace was removed but its secrets were not. Secrets live outside the workspace's
282
+ checkout, in `$WORKSPACES_HOME/vault/<workspace-id>.enc`, with no other record of the
283
+ link, so a workspace deleted some other way (by hand, or `git worktree remove`) leaves
284
+ its file behind. Clean those up with:
285
+
286
+ ```sh
287
+ bin/workspaces purge-secrets --dry-run # list secrets whose workspace no longer exists
288
+ bin/workspaces purge-secrets # delete them
289
+ ```
290
+
291
+ It refuses to run if the workspaces directory cannot be read, so an unmounted volume is
292
+ never mistaken for "every workspace is gone".
293
+
294
+ Another store can be added behind the same small interface (`available?`/`problem`,
295
+ `read(scope)`, `write(scope, entries)`, `purge(scope)`, and optionally `scopes` to
296
+ list what it holds so `purge-secrets` can find orphans): subclass
297
+ `Workspaces::Vault::Provider` and call `Workspaces::Vault.register('name', YourClass)`
298
+ from a file loaded with `serve --require`, then set `WORKSPACES_VAULT=name`.
299
+
245
300
  ### Editable environment presets
246
301
 
247
302
  Recipes can define editable environment defaults and named presets in
@@ -258,20 +313,20 @@ environment_presets:
258
313
  DEMO_MODE: "false"
259
314
  ```
260
315
 
261
- Each preset maps editable variable names to values. In the workspace editor,
262
- choosing a preset fills the draft and replaces its custom editor lines rather
263
- than merging with them. Selection does not save or restart the workspace; choose
264
- **Save & restart** to apply the draft. Presets are not applied automatically,
316
+ Each preset maps editable variable names to values. In the workspace settings,
317
+ choosing a preset replaces the visible variable rows rather than merging with
318
+ them (secret rows are left alone). Selection does not save or restart the
319
+ workspace; choose **Save & restart** to apply the draft. Presets are not applied automatically,
265
320
  and defining them requires no JavaScript edits. Preset keys must be declared in
266
321
  `default_editable_env`; recipes reject undeclared keys and secret-like values.
267
- Hidden environment values are never exposed in the editor or preset choices.
322
+ Hidden environment values are never exposed in the settings or preset choices.
268
323
 
269
324
  Use `create --branch main --preset Demo` to save a preset for a new, still-idle
270
325
  workspace. `start ID --preset Demo` applies it before preparing a stopped
271
326
  workspace; `restart ID --preset 'Demo off'` switches a running workspace and
272
327
  restarts its server. `prepare ID --preset Demo` switches and rebuilds. An
273
328
  unknown name is rejected without changing saved settings. Selecting a preset
274
- replaces the editable settings (including custom editor lines) but preserves
329
+ replaces the editable settings (including custom rows) but preserves
275
330
  hidden legacy overrides. A running workspace requires `restart --preset` or
276
331
  `prepare --preset`, not `start --preset`.
277
332
 
@@ -2,6 +2,10 @@ module Workspaces
2
2
  module ChildEnv
3
3
  module_function
4
4
 
5
+ # Held by the front door alone. Anything a step, the server or `exec` starts must never see the
6
+ # key that opens the secret vault, however it was set.
7
+ WITHHELD = %w(WORKSPACES_MASTER_KEY).freeze
8
+
5
9
  def for_workspace(workspace_path:, extra_env: {}, parent_env: ENV.to_h)
6
10
  env = parent_env.to_h.transform_keys(&:to_s).dup
7
11
  if defined?(::Bundler)
@@ -10,7 +14,7 @@ module Workspaces
10
14
  clean.key?(name) ? env[name] = clean[name] : env.delete(name)
11
15
  end
12
16
  end
13
- env.delete_if { |key, _| bundler_key?(key) }
17
+ env.delete_if { |key, _| bundler_key?(key) || WITHHELD.include?(key) }
14
18
 
15
19
  gemfile = File.join(workspace_path.to_s, 'Gemfile')
16
20
  env['BUNDLE_GEMFILE'] = gemfile
@@ -18,7 +22,7 @@ module Workspaces
18
22
 
19
23
  extra_env.to_h.each do |name, value|
20
24
  key = name.to_s
21
- next if bundler_key?(key)
25
+ next if bundler_key?(key) || WITHHELD.include?(key)
22
26
 
23
27
  if value.nil?
24
28
  env.delete(key)
@@ -17,7 +17,7 @@ module Workspaces
17
17
  COMMANDS = {
18
18
  'serve' => :serve, 'create' => :create, 'list' => :list, 'show' => :show,
19
19
  'start' => :lifecycle, 'prepare' => :lifecycle, 'restart' => :lifecycle,
20
- 'stop' => :stop, 'remove' => :remove, 'resume' => :resume, 'keep-awake' => :keep_awake, 'help' => :help
20
+ 'stop' => :stop, 'remove' => :remove, 'purge-secrets' => :purge_secrets, 'resume' => :resume, 'keep-awake' => :keep_awake, 'help' => :help
21
21
  }.freeze
22
22
  def self.run(argv)
23
23
  new.run(argv.dup)
@@ -88,6 +88,7 @@ module Workspaces
88
88
  parser.on('--from REF') { |value| options[:from] = value }
89
89
  parser.on('--json') { options[:json] = true }
90
90
  parser.on('--force') { options[:force] = true }
91
+ parser.on('--dry-run') { options[:dry_run] = true }
91
92
  parser.on('--off') { options[:off] = true }
92
93
  end
93
94
 
@@ -207,8 +208,21 @@ module Workspaces
207
208
  end
208
209
 
209
210
  def remove(id, options)
210
- registry.remove(id, force: options[:force])
211
- emit({ 'id' => id, 'removed' => true, 'branch_preserved' => true }, options)
211
+ leftover = registry.remove(id, force: options[:force])
212
+ leftover = [] unless leftover.is_a?(Array)
213
+ emit({ 'id' => id, 'removed' => true, 'branch_preserved' => true, 'secrets_left' => leftover }, options)
214
+ return if leftover.empty?
215
+
216
+ warn "The workspace was removed, but its secrets could not be: #{leftover.join('; ')}. " \
217
+ 'Fix that, then run `workspaces purge-secrets`.'
218
+ raise ArgumentError, 'Secrets were left behind'
219
+ end
220
+
221
+ # Deletes stored secrets whose workspace no longer exists. `--dry-run` lists them without deleting.
222
+ def purge_secrets(_id, options)
223
+ result = registry.sweep_orphaned_secrets(dry_run: options[:dry_run])
224
+ emit(result.merge('dry_run' => !!options[:dry_run]), options)
225
+ raise ArgumentError, "Could not remove: #{result['problems'].join('; ')}" unless result['problems'].empty?
212
226
  end
213
227
 
214
228
  def execute(argv)
@@ -243,7 +257,7 @@ module Workspaces
243
257
  'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
244
258
  'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
245
259
  'resume ID [--from STEP | --only STEP] [--json] | stop ID | keep-awake ID [--off] | ' \
246
- 'exec ID -- COMMAND... | remove ID [--force]'
260
+ 'exec ID -- COMMAND... | remove ID [--force] | purge-secrets [--dry-run]'
247
261
  end
248
262
 
249
263
  def help(*)
@@ -63,6 +63,17 @@ module Workspaces
63
63
  })
64
64
  end
65
65
 
66
+ # Saves the settings form (rows of name / value / secret) and starts the workspace, restarting it
67
+ # if it is already up. See EnvironmentOverrides#replace_rows.
68
+ def replace_environment_rows(id, rows:)
69
+ restart = Backend.new(id).running? || sleeping?(id)
70
+ launch(id, restart: restart, force: true,
71
+ prelaunch: lambda { |_workspace|
72
+ defaults = Recipe.new.default_editable_env
73
+ EnvironmentOverrides.new(id).replace_rows(rows, defaults: defaults)
74
+ })
75
+ end
76
+
66
77
  def replace_editable_environment(id, text:)
67
78
  restart = Backend.new(id).running? || sleeping?(id)
68
79
  launch(id, restart: restart, force: true,
@@ -259,6 +270,8 @@ module Workspaces
259
270
  'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
260
271
  'environment_keys' => environment_keys(id),
261
272
  'editable_environment' => editable_environment(id),
273
+ 'secret_names' => secret_names(id),
274
+ 'vault' => Vault.status,
262
275
  'environment_presets' => environment_presets
263
276
  }
264
277
  end
@@ -475,6 +488,13 @@ module Workspaces
475
488
  []
476
489
  end
477
490
 
491
+ # Names only. A secret's value is never part of a snapshot, a status response or a page.
492
+ def secret_names(id)
493
+ EnvironmentOverrides.new(id).secret_names
494
+ rescue EnvironmentOverrides::Invalid
495
+ []
496
+ end
497
+
478
498
  def editable_environment(id)
479
499
  EnvironmentOverrides.new(id).editable_values(Recipe.new.default_editable_env)
480
500
  rescue Recipe::Invalid, EnvironmentOverrides::Invalid
@@ -5,14 +5,17 @@
5
5
  const status = document.getElementById('environment-status');
6
6
  const fieldset = form.querySelector('[data-environment-fieldset]');
7
7
  const submit = form.querySelector('[data-env-submit]');
8
- const editor = document.getElementById('editable-env');
8
+ const rowsContainer = document.getElementById('env-rows');
9
+ const rowTemplate = document.getElementById('env-row-template');
10
+ const addButton = form.querySelector('[data-env-add-row]');
9
11
  const presetButtons = Array.from(form.querySelectorAll('[data-environment-preset]'));
10
12
  const presetGroup = form.querySelector('[data-preset-controls]');
11
- const initialDraft = editor ? editor.value : '';
12
13
  let lastAnnounced = '';
13
14
  let activeBusy = document.getElementById('workspace').dataset.busy === 'true';
14
15
  let presets = {};
15
16
  let selectedPreset = null;
17
+ let nextIndex = 0;
18
+ let initialSignature = '';
16
19
 
17
20
  try {
18
21
  presets = JSON.parse(form.dataset.environmentPresets || '{}');
@@ -22,17 +25,60 @@
22
25
  const hasPresets = Object.keys(presets).length > 0;
23
26
  if (presetGroup && hasPresets) presetGroup.hidden = false;
24
27
 
25
- function normalize(value) {
26
- return (value || '')
27
- .replace(/\r\n?/g, '\n')
28
- .trimEnd();
28
+ // A preset is a list of { name, value }. A row's state is what the person typed. Secret rows are
29
+ // never part of any comparison, so a preset can match while secrets are present.
30
+ function rowElements() {
31
+ return rowsContainer ? Array.from(rowsContainer.querySelectorAll('[data-env-row]')) : [];
29
32
  }
30
33
 
31
- function matchingPresetNameForDraft(value) {
32
- const normalizedValue = normalize(value);
33
- return Object.keys(presets).find(
34
- (name) => normalize(presets[name]) === normalizedValue
35
- ) || null;
34
+ // The value input is missing on a stored secret until Replace is clicked, so it can be null.
35
+ function fieldsOf(row) {
36
+ return {
37
+ name: row.querySelector('.env-row__name'),
38
+ value: row.querySelector('[data-env-value]'),
39
+ secret: row.querySelector('[data-env-secret]'),
40
+ remove: row.querySelector('[data-env-remove-row]'),
41
+ stored: row.querySelector('[data-env-stored]'),
42
+ replace: row.querySelector('[data-env-replace]')
43
+ };
44
+ }
45
+
46
+ const valueOf = (fields) => (fields.value ? fields.value.value || '' : '');
47
+
48
+ // Keeps password managers out of every input this script creates: autocomplete is ignored on
49
+ // password fields, so each manager's own opt-out goes with it.
50
+ function noAutofill(input) {
51
+ input.autocomplete = 'off';
52
+ input.setAttribute('autocomplete', 'off');
53
+ input.setAttribute('data-1p-ignore', '');
54
+ input.setAttribute('data-lpignore', 'true');
55
+ input.setAttribute('data-bwignore', '');
56
+ }
57
+
58
+ function visibleEntries() {
59
+ return rowElements()
60
+ .map((row) => {
61
+ const fields = fieldsOf(row);
62
+ return {
63
+ name: (fields.name.value || '').trim(),
64
+ value: valueOf(fields),
65
+ secret: !!(fields.secret && fields.secret.checked)
66
+ };
67
+ })
68
+ .filter((entry) => !entry.secret && (entry.name || entry.value));
69
+ }
70
+
71
+ function signatureOf(entries) {
72
+ return JSON.stringify(
73
+ entries
74
+ .map((entry) => [entry.name, entry.value])
75
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
76
+ );
77
+ }
78
+
79
+ function matchingPresetName() {
80
+ const current = signatureOf(visibleEntries());
81
+ return Object.keys(presets).find((name) => signatureOf(presets[name]) === current) || null;
36
82
  }
37
83
 
38
84
  function updatePresetButtons(nextSelected) {
@@ -69,15 +115,13 @@
69
115
  if (fieldset) fieldset.disabled = activeBusy;
70
116
  if (submit) submit.disabled = activeBusy;
71
117
  presetButtons.forEach((button) => {
72
- button.disabled = activeBusy;
118
+ button.disabled = activeBusy || !hasPresets;
73
119
  });
74
120
  }
75
121
 
76
122
  function updateStatus(busy) {
77
123
  if (busy)
78
- setStatus(
79
- 'Workspace is busy. Environment updates are temporarily disabled.'
80
- );
124
+ setStatus('Workspace is busy. Environment updates are temporarily disabled.');
81
125
  else if (status.textContent === 'Workspace is busy. Environment updates are temporarily disabled.') {
82
126
  status.textContent = '';
83
127
  lastAnnounced = '';
@@ -86,75 +130,173 @@
86
130
 
87
131
  function announceUnsaved(message) {
88
132
  if (activeBusy) return;
89
- const nextMessage = message || 'Draft updated locally and not saved yet. Use ' + submitLabel() + ' to apply changes.';
133
+ const nextMessage =
134
+ message || 'Draft updated locally and not saved yet. Use ' + submitLabel() + ' to apply changes.';
90
135
  status.textContent = nextMessage;
91
136
  lastAnnounced = nextMessage;
92
137
  }
93
138
 
94
- function replaceDraft(value, message) {
95
- if (!editor) return;
96
- editor.value = value || '';
97
- updatePresetButtons(matchingPresetNameForDraft(editor.value));
98
- announceUnsaved(message);
99
- }
100
-
101
139
  function clearStatus() {
102
140
  if (activeBusy) return;
103
141
  status.textContent = '';
104
142
  lastAnnounced = '';
105
143
  }
106
144
 
145
+ // The checkbox of a secret that is already stored is disabled (and a hidden field carries it), so a
146
+ // saved secret cannot be turned back into a visible variable. Ticking Secret on a new row switches
147
+ // its value to a password field; the value is write-only from then on.
148
+ function syncRow(row) {
149
+ const fields = fieldsOf(row);
150
+ const secret = !!(fields.secret && fields.secret.checked);
151
+ if (secret) row.setAttribute('data-secret', 'true');
152
+ else row.removeAttribute('data-secret');
153
+ if (!fields.value) return;
154
+ fields.value.type = secret ? 'password' : 'text';
155
+ noAutofill(fields.value);
156
+ if (row.hasAttribute('data-stored')) fields.value.placeholder = 'New value (leave blank to keep)';
157
+ else fields.value.placeholder = secret ? 'Value' : 'value';
158
+ }
159
+
160
+ // Replace adds the value input to a stored secret. Until then the page has no password field for
161
+ // it at all.
162
+ function revealReplaceInput(row) {
163
+ const fields = fieldsOf(row);
164
+ if (fields.value || !fields.stored) return;
165
+ const input = document.createElement('input');
166
+ input.className = 'env-row__value';
167
+ input.name = (fields.name.name || '').replace(/\[name\]$/, '[value]');
168
+ input.type = 'password';
169
+ input.value = '';
170
+ input.placeholder = 'New value (leave blank to keep)';
171
+ input.setAttribute('aria-label', 'New value');
172
+ input.setAttribute('spellcheck', 'false');
173
+ input.setAttribute('autocapitalize', 'off');
174
+ input.setAttribute('data-env-value', '');
175
+ noAutofill(input);
176
+ fields.stored.parentNode.insertBefore(input, fields.stored);
177
+ fields.stored.parentNode.removeChild(fields.stored);
178
+ input.focus();
179
+ }
180
+
181
+ function bindRow(row) {
182
+ const fields = fieldsOf(row);
183
+ if (fields.remove) {
184
+ fields.remove.hidden = false;
185
+ fields.remove.addEventListener('click', () => {
186
+ if (activeBusy) return;
187
+ row.parentNode.removeChild(row);
188
+ draftChanged();
189
+ });
190
+ }
191
+ if (fields.replace) {
192
+ fields.replace.hidden = false;
193
+ fields.replace.addEventListener('click', () => {
194
+ if (activeBusy) return;
195
+ revealReplaceInput(row);
196
+ });
197
+ }
198
+ ['input', 'change'].forEach((type) => {
199
+ row.addEventListener(type, (event) => {
200
+ if (event && event.target && event.target.matches && event.target.matches('[data-env-secret]'))
201
+ syncRow(row);
202
+ draftChanged();
203
+ });
204
+ });
205
+ syncRow(row);
206
+ }
207
+
208
+ function addRow(entry) {
209
+ if (!rowTemplate || !rowsContainer) return null;
210
+ const holder = document.createElement('div');
211
+ holder.innerHTML = rowTemplate.innerHTML.split('__INDEX__').join(String(nextIndex++));
212
+ const row = holder.querySelector('[data-env-row]');
213
+ if (!row) return null;
214
+ rowsContainer.append(row);
215
+ const fields = fieldsOf(row);
216
+ if (entry) {
217
+ fields.name.value = entry.name || '';
218
+ if (fields.value) fields.value.value = entry.value || '';
219
+ }
220
+ bindRow(row);
221
+ return row;
222
+ }
223
+
224
+ function draftChanged() {
225
+ updatePresetButtons(matchingPresetName());
226
+ const unchanged = rowElements().length === 0 ? false : currentSignature() === initialSignature;
227
+ if (unchanged) clearStatus();
228
+ else announceUnsaved();
229
+ }
230
+
231
+ function currentSignature() {
232
+ return JSON.stringify(
233
+ rowElements().map((row) => {
234
+ const fields = fieldsOf(row);
235
+ const secret = !!(fields.secret && fields.secret.checked);
236
+ // A secret's value is kept out of the signature, but whether one has been typed is not: a
237
+ // pending replacement is an unsaved change.
238
+ return [fields.name.value, secret ? '' : valueOf(fields), secret, secret && valueOf(fields) !== ''];
239
+ })
240
+ );
241
+ }
242
+
243
+ // Loading a preset replaces the visible variables only. Secrets are never touched by a preset.
244
+ function loadPreset(name) {
245
+ const entries = presets[name] || [];
246
+ rowElements().forEach((row) => {
247
+ const fields = fieldsOf(row);
248
+ const isSecret = !!(fields.secret && fields.secret.checked);
249
+ if (!isSecret) row.parentNode.removeChild(row);
250
+ });
251
+ entries.forEach((entry) => addRow(entry));
252
+ updatePresetButtons(name);
253
+ announceUnsaved('Preset loaded into draft only. Use ' + submitLabel() + ' to apply changes.');
254
+ }
255
+
107
256
  form.addEventListener('submit', () => {
108
257
  setStatus('Submitting environment settings…');
109
258
  });
110
259
 
111
- if (editor)
112
- editor.addEventListener('input', () => {
113
- if (editor.value === initialDraft) {
114
- updatePresetButtons(matchingPresetNameForDraft(initialDraft));
115
- clearStatus();
116
- return;
117
- }
118
- updatePresetButtons(matchingPresetNameForDraft(editor.value));
119
- announceUnsaved();
120
- });
121
-
122
260
  presetButtons.forEach((button) => {
123
261
  button.addEventListener('click', () => {
124
262
  if (activeBusy) return;
125
263
  const nextValue = button.dataset.environmentPreset;
126
264
  if (!nextValue) return;
127
265
  if (!Object.prototype.hasOwnProperty.call(presets, nextValue)) return;
128
- replaceDraft(
129
- presets[nextValue],
130
- 'Preset loaded into draft only. Use ' + submitLabel() + ' to apply changes.'
131
- );
266
+ loadPreset(nextValue);
267
+ });
268
+ });
269
+
270
+ if (addButton) {
271
+ addButton.hidden = false;
272
+ addButton.addEventListener('click', () => {
273
+ if (activeBusy) return;
274
+ const row = addRow(null);
275
+ if (row) fieldsOf(row).name.focus();
132
276
  });
277
+ }
278
+
279
+ // Rows the server rendered already carry numbered names; new rows continue after the highest.
280
+ rowElements().forEach((row) => {
281
+ const match = /env\[(\d+)\]/.exec(fieldsOf(row).name.name || '');
282
+ if (match) nextIndex = Math.max(nextIndex, Number(match[1]) + 1);
283
+ bindRow(row);
133
284
  });
285
+ initialSignature = currentSignature();
134
286
 
135
287
  window.WorkspaceEnvironment = {
136
288
  toggleBusy,
137
289
  updateStatus,
138
290
  setStatus,
139
291
  setSubmitLabel,
292
+ // The rows are server-rendered and the person may be editing them, so a poll never rewrites
293
+ // them; it only keeps the selected preset in step when nothing has been touched.
140
294
  syncFromSnapshot(snapshot) {
141
- if (!snapshot || presetButtons.length === 0 || !editor) return;
142
- const rendered = snapshot.editable_environment;
143
- if (!rendered || typeof rendered !== 'object') return;
144
- const expected = Object.keys(rendered)
145
- .sort()
146
- .map((name) => name + '=' + rendered[name])
147
- .join('\n');
148
- if (editor === document.activeElement) return;
149
- if (editor.value !== expected) return;
150
- updatePresetButtons(matchingPresetNameForDraft(expected));
295
+ if (!snapshot || presetButtons.length === 0) return;
296
+ if (rowsContainer && rowsContainer.contains(document.activeElement)) return;
297
+ updatePresetButtons(matchingPresetName());
151
298
  }
152
299
  };
153
- updatePresetButtons(matchingPresetNameForDraft(initialDraft));
154
- if (!hasPresets) {
155
- presetButtons.forEach((button) => {
156
- button.disabled = true;
157
- });
158
- }
300
+ updatePresetButtons(matchingPresetName());
159
301
  toggleBusy(activeBusy);
160
302
  })();
@@ -386,6 +386,38 @@ h1 {
386
386
  overflow: auto;
387
387
  }
388
388
 
389
+ .env-rows { display: grid; gap: var(--space-2); }
390
+
391
+ .env-row {
392
+ display: grid;
393
+ grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 2fr) auto auto;
394
+ gap: var(--space-2);
395
+ align-items: center;
396
+ }
397
+
398
+ .env-row input[type='text'],
399
+ .env-row input[type='password'],
400
+ .env-row .env-row__name {
401
+ min-inline-size: 0;
402
+ padding: var(--space-2) var(--space-3);
403
+ color: var(--text);
404
+ background: var(--bg);
405
+ border: 1px solid var(--border);
406
+ border-radius: var(--space-1);
407
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
408
+ }
409
+
410
+ .env-row[data-secret] .env-row__value { border-style: dashed; }
411
+ .env-row__stored { display: inline-flex; align-items: center; gap: var(--space-2); font-size: 13px; }
412
+ .env-row__secret { display: inline-flex; align-items: center; gap: var(--space-1); font-size: 13px; white-space: nowrap; }
413
+ .env-row-actions { display: flex; gap: var(--space-2); }
414
+ .env-vault-note { color: var(--warning); }
415
+
416
+ @media (max-width: 560px) {
417
+ .env-row { grid-template-columns: 1fr 1fr; }
418
+ .env-row .env-row__name, .env-row .env-row__value { grid-column: 1 / -1; }
419
+ }
420
+
389
421
  .env-actions {
390
422
  display: flex;
391
423
  flex-wrap: wrap;
@@ -44,9 +44,21 @@
44
44
  </div>
45
45
  <% end %>
46
46
  <div class="stack stack--tight">
47
- <label class="env-input-label" for="editable-env">Environment variables</label>
48
- <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
47
+ <p class="env-input-label" id="env-rows-label">Environment variables</p>
48
+ <div class="env-rows" id="env-rows" role="group" aria-labelledby="env-rows-label" data-env-rows>
49
+ <% environment_rows.each_with_index do |row, index| %>
50
+ <%= env_row_html(index, row) %>
51
+ <% end %>
52
+ </div>
53
+ <div class="env-row-actions">
54
+ <button type="button" class="button button--subtle button--small" data-env-add-row hidden>Add variable</button>
55
+ </div>
56
+ <% unless vault_status['available'] %>
57
+ <p class="muted env-vault-note" id="env-vault-note"><%= escape(vault_status['problem'] || 'Secrets are not available.') %></p>
58
+ <% end %>
59
+ <p class="muted env-secret-help">Secrets are write-only: once saved their values are never shown again. Leave a secret's value blank to keep what is stored.</p>
49
60
  </div>
61
+ <template id="env-row-template"><%= env_row_html('__INDEX__', { 'name' => '', 'value' => '', 'secret' => false }) %></template>
50
62
  </fieldset>
51
63
  <div class="env-actions">
52
64
  <button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>><%= escape(@presentation['environment_submit']) %></button>
@@ -138,28 +138,86 @@ module Workspaces
138
138
  keys.map(&:to_s).sort
139
139
  end
140
140
 
141
- def editable_environment_text
141
+ # The rows the settings form starts with: visible variables with their values, then secrets by
142
+ # name only. A secret row never carries a value, so there is nothing to leak into the page.
143
+ def environment_rows
142
144
  values = @snapshot['editable_environment']
143
- return '' unless values.is_a?(Hash)
145
+ plain = values.is_a?(Hash) ? values.sort.map { |name, value| { 'name' => name.to_s, 'value' => value.to_s, 'secret' => false } } : []
146
+ names = @snapshot['secret_names']
147
+ secrets = names.is_a?(Array) ? names.map(&:to_s).sort.map { |name| { 'name' => name, 'value' => '', 'secret' => true } } : []
148
+ plain + secrets
149
+ end
144
150
 
145
- values.sort.map { |name, value| "#{name}=#{value}" }.join("\n")
151
+ # What a preset loads: its rows in the same shape. Secrets are untouched by presets.
152
+ def environment_preset_rows
153
+ environment_presets_raw.transform_values do |entries|
154
+ entries.sort.map { |name, value| { 'name' => name.to_s, 'value' => value.to_s } }
155
+ end
146
156
  end
147
157
 
148
- def environment_presets
158
+ # One row of the settings form. Plain rows show their value. Secret rows have a password field,
159
+ # never pre-filled, with a placeholder saying a value is stored. The secret checkbox is the only
160
+ # thing that decides which store a row goes to, and it is disabled for a stored secret so a
161
+ # saved secret cannot be quietly turned into a visible variable.
162
+ # Keeps password managers from offering to fill, save or generate anything for these inputs. No
163
+ # single attribute does it: `autocomplete="off"` is ignored on password fields, so each manager's
164
+ # own opt-out goes alongside it (1Password, LastPass, Bitwarden).
165
+ NO_AUTOFILL = 'autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore'
166
+
167
+ # One row of the settings form.
168
+ #
169
+ # A stored secret has no value input at all: just its name, a note, and a Replace button that
170
+ # adds the input when it is clicked. So the page holds no password field for a password manager
171
+ # to find until someone asks to replace one. (Without script, the input sits inside <noscript> so
172
+ # a secret can still be replaced.) A row posted with no value keeps what is stored.
173
+ #
174
+ # The secret checkbox is disabled for a stored secret, with a hidden field carrying it, so a
175
+ # saved secret cannot be quietly turned into a visible variable.
176
+ def env_row_html(index, row)
177
+ secret = row['secret']
178
+ stored = secret && !row['name'].to_s.empty?
179
+ <<~HTML.delete("\n")
180
+ <div class="env-row" data-env-row#{secret ? ' data-secret="true"' : ''}#{stored ? ' data-stored="true"' : ''}>
181
+ <input class="env-row__name" name="env[#{index}][name]" value="#{escape(row['name'])}" placeholder="NAME" aria-label="Variable name" spellcheck="false" #{NO_AUTOFILL} autocapitalize="off">
182
+ #{stored ? stored_value_html(index) : value_input_html(index, row)}
183
+ <label class="env-row__secret"><input type="checkbox" name="env[#{index}][secret]" value="1" data-env-secret#{secret ? ' checked' : ''}#{stored ? ' disabled' : ''}> Secret</label>
184
+ #{stored ? "<input type=\"hidden\" name=\"env[#{index}][secret]\" value=\"1\">" : ''}
185
+ <button type="button" class="button button--subtle button--small" data-env-remove-row aria-label="Remove variable" hidden>Remove</button>
186
+ </div>
187
+ HTML
188
+ end
189
+
190
+ def value_input_html(index, row, placeholder: nil)
191
+ secret = row['secret']
192
+ placeholder ||= secret ? 'Value' : 'value'
193
+ %(<input class="env-row__value" name="env[#{index}][value]" type="#{secret ? 'password' : 'text'}" value="#{secret ? '' : escape(row['value'])}" placeholder="#{escape(placeholder)}" aria-label="Value" spellcheck="false" #{NO_AUTOFILL} autocapitalize="off" data-env-value>)
194
+ end
195
+
196
+ def stored_value_html(index)
197
+ '<span class="env-row__stored" data-env-stored><span class="muted">Stored. Write-only.</span> ' \
198
+ '<button type="button" class="button button--subtle button--small" data-env-replace hidden>Replace</button></span>' \
199
+ "<noscript>#{value_input_html(index, { 'secret' => true }, placeholder: 'New value (leave blank to keep)')}</noscript>"
200
+ end
201
+
202
+ def environment_presets_raw
149
203
  presets = @snapshot['environment_presets']
150
204
  return {} unless presets.is_a?(Hash)
151
205
 
152
- presets.each_with_object({}) do |(name, entries), memo|
153
- next unless name.respond_to?(:to_s)
154
- next unless entries.is_a?(Hash)
206
+ presets.select { |name, entries| name.respond_to?(:to_s) && entries.is_a?(Hash) }.transform_keys(&:to_s)
207
+ end
155
208
 
156
- lines = entries.sort.map { |entry_name, value| "#{entry_name}=#{value}" }.join("\n")
157
- memo[name.to_s] = lines
158
- end
209
+ def vault_status
210
+ vault = @snapshot['vault']
211
+ vault.is_a?(Hash) ? vault : { 'available' => false, 'problem' => nil }
212
+ end
213
+
214
+ # The preset names and values the buttons are built from.
215
+ def environment_presets
216
+ environment_presets_raw
159
217
  end
160
218
 
161
219
  def environment_presets_json
162
- JSON.generate(environment_presets)
220
+ JSON.generate(environment_preset_rows)
163
221
  end
164
222
 
165
223
  # The lead's primary action. `open` is rendered separately as a link (it needs the
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'securerandom'
5
+ require_relative 'vault'
5
6
 
6
7
  module Workspaces
7
8
  class EnvironmentOverrides
@@ -13,6 +14,7 @@ module Workspaces
13
14
  MAX_ENTRIES = 100
14
15
  MAX_VALUE_BYTES = 8192
15
16
  MAX_BODY_BYTES = 128 * 1024
17
+ MAX_ROWS = 200
16
18
 
17
19
  def self.validate_entries!(entries)
18
20
  raise Invalid, 'Environment overrides must be a mapping' unless entries.is_a?(Hash)
@@ -45,12 +47,26 @@ module Workspaces
45
47
  @home = home
46
48
  end
47
49
 
50
+ # Everything a step should see: saved variables and secrets. Raises Vault::Error when secrets
51
+ # exist but cannot be read, so a run fails with a reason instead of starting without them.
48
52
  def load
53
+ load_plain.merge(secret_values)
54
+ end
55
+
56
+ # The variables that are safe to show. No secrets, and no vault access.
57
+ def load_plain
49
58
  load_file(file_path).merge(load_file(editable_file_path))
50
59
  end
51
60
 
52
61
  def keys
53
- load.keys.sort
62
+ (load_plain.keys + secret_names).uniq.sort
63
+ end
64
+
65
+ # Names only, never values. Empty when the vault cannot be opened.
66
+ def secret_names
67
+ secret_values.keys.sort
68
+ rescue Vault::Error
69
+ []
54
70
  end
55
71
 
56
72
  def editable_values(defaults)
@@ -72,7 +88,28 @@ module Workspaces
72
88
  raise Invalid, 'Secret-like environment names cannot be stored in the visible editor'
73
89
  end
74
90
  persist_editable(entries, updated, defaults)
75
- load
91
+ load_plain
92
+ end
93
+
94
+ # Saves the settings form: a list of rows, each { 'name', 'value', 'secret' }. Plain rows are
95
+ # stored as editable settings. Secret rows go to the vault and are write-only: a secret row
96
+ # with no value keeps the value already stored, and a secret that is no longer listed is removed.
97
+ #
98
+ # Nothing is changed unless every row is valid and the vault can be used. Messages name
99
+ # variables, never values.
100
+ def replace_rows(rows, defaults:)
101
+ raise Invalid, 'Environment rows must be a list' unless rows.is_a?(Array)
102
+ raise Invalid, "Environment rows are limited to #{MAX_ROWS}" if rows.length > MAX_ROWS
103
+
104
+ self.class.validate_entries!(defaults)
105
+ plain, secrets = split_rows(rows)
106
+ self.class.validate_entries!(plain)
107
+ self.class.validate_entries!(secrets.compact)
108
+ raise Invalid, 'Environment overrides are limited to 100 entries' if plain.length + secrets.length > MAX_ENTRIES
109
+
110
+ write_secrets(secrets)
111
+ persist_editable(plain, plain.reject { |name, value| defaults[name] == value }, defaults)
112
+ load_plain
76
113
  end
77
114
 
78
115
  def apply_preset(name, recipe: Recipe.new)
@@ -111,9 +148,12 @@ module Workspaces
111
148
  updated
112
149
  end
113
150
 
151
+ # Removes saved variables and secrets. Returns the secrets that could not be removed (see
152
+ # Vault.purge_everywhere), empty when everything went.
114
153
  def clear!
115
154
  delete_file!
116
155
  delete_file!(editable_file_path)
156
+ Vault.purge_everywhere(workspace_id)
117
157
  end
118
158
 
119
159
  def scrub(value)
@@ -261,10 +301,69 @@ module Workspaces
261
301
  raise Invalid, 'Environment storage does not allow symlinks' if path.symlink?
262
302
  end
263
303
 
304
+ # Filtering still hides every secret that can be read, even when something else is wrong.
264
305
  def scrub_values
265
- load.values.reject(&:empty?).uniq.sort_by { |value| -value.bytesize }
266
- rescue Invalid
306
+ values = [safely { load_plain.values }, safely { secret_values.values }].flatten
307
+ values.reject(&:empty?).uniq.sort_by { |value| -value.bytesize }
308
+ end
309
+
310
+ def safely
311
+ yield
312
+ rescue Invalid, Vault::Error
267
313
  []
268
314
  end
315
+
316
+ def secret_values
317
+ Vault.provider.read(workspace_id)
318
+ end
319
+
320
+ def split_rows(rows)
321
+ plain = {}
322
+ secrets = {}
323
+ rows.each do |row|
324
+ raise Invalid, 'Each environment row needs a name and a value' unless row.is_a?(Hash)
325
+
326
+ name = row['name'].to_s.strip
327
+ value = row['value'].to_s
328
+ next if name.empty? && value.empty?
329
+
330
+ raise Invalid, 'Every variable needs a name' if name.empty?
331
+ raise Invalid, "#{name} is listed more than once" if plain.key?(name) || secrets.key?(name)
332
+
333
+ if [true, '1', 'true', 'on'].include?(row['secret'])
334
+ validate_name!(name)
335
+ secrets[name] = value.empty? ? nil : value
336
+ else
337
+ validate_plain_row!(name, value)
338
+ plain[name] = value
339
+ end
340
+ end
341
+ [plain, secrets]
342
+ end
343
+
344
+ def validate_plain_row!(name, value)
345
+ validate_name!(name)
346
+ validate_value!(value)
347
+ raise Invalid, "#{name} looks like a secret: tick Secret so it is stored write-only" if sensitive_name?(name)
348
+ raise Invalid, "#{name}: values must be a single line" if value.match?(/[\r\n]/)
349
+ end
350
+
351
+ # `submitted` maps each secret's name to its new value, or nil when the row was left blank. A blank
352
+ # row keeps the stored value. Existing secrets are never replaced blindly: if the vault cannot be
353
+ # read (wrong or missing key) an edit is refused rather than overwriting what is stored.
354
+ def write_secrets(submitted)
355
+ vault = Vault.provider
356
+ unless vault.available?
357
+ raise Vault::Unavailable, vault.problem unless submitted.empty?
358
+
359
+ return
360
+ end
361
+
362
+ stored = vault.read(workspace_id)
363
+ result = submitted.to_h do |name, value|
364
+ [name, value || stored.fetch(name) { raise Invalid, "#{name} is a new secret and needs a value" }]
365
+ end
366
+ vault.write(workspace_id, result) unless result == stored
367
+ end
269
368
  end
270
369
  end
@@ -148,7 +148,9 @@ module Workspaces
148
148
  return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
149
149
 
150
150
  params = environment_params(request)
151
- updated = if params.key?('editable_env')
151
+ updated = if params.key?('env')
152
+ @coordinator.replace_environment_rows(id, rows: parse_environment_rows(params))
153
+ elsif params.key?('editable_env')
152
154
  @coordinator.replace_editable_environment(id, text: parse_editable_environment(params))
153
155
  else
154
156
  @coordinator.update_environment(id, **parse_environment_changes(params))
@@ -158,6 +160,8 @@ module Workspaces
158
160
  redirect(Config.dashboard_url(id))
159
161
  rescue EnvironmentOverrides::Invalid => e
160
162
  response(422, e.message)
163
+ rescue Vault::Error => e
164
+ response(422, e.message)
161
165
  end
162
166
 
163
167
  def remove(request, id)
@@ -167,9 +171,13 @@ module Workspaces
167
171
  return response(413, 'Deletion confirmation is too large') if request.content_length.to_i > 512
168
172
  return response(422, 'Type the exact workspace ID to confirm deletion') unless request.POST['confirm_id'] == id
169
173
 
170
- @registry.remove(id, force: true)
174
+ leftover = @registry.remove(id, force: true)
171
175
  @tracker.forget(id)
172
- redirect("#{Config.public_origin}/workspaces")
176
+ return redirect("#{Config.public_origin}/workspaces") if !leftover.is_a?(Array) || leftover.empty?
177
+
178
+ # The workspace is gone, but its secrets are not, and saying "removed" would be untrue.
179
+ response(500, "The workspace was removed, but its stored secrets could not be: #{leftover.join('; ')}. " \
180
+ 'Fix that, then run `workspaces purge-secrets`.')
173
181
  rescue ArgumentError, Worktree::CommandFailedError, SystemCallError
174
182
  response(409, 'Workspace was not removed. Check the server logs and retry when safe.')
175
183
  end
@@ -204,6 +212,24 @@ module Workspaces
204
212
  { set: normalize_set(params['set']), remove: normalize_remove(params['remove']) }
205
213
  end
206
214
 
215
+ # The form posts env[0][name], env[0][value] and env[0][secret] for each row. Rows are taken in
216
+ # the order of their numbers; anything that is not a mapping of strings is refused.
217
+ def parse_environment_rows(params)
218
+ rows = params['env']
219
+ raise EnvironmentOverrides::Invalid, 'Environment rows must be a numbered list' unless rows.is_a?(Hash)
220
+ unless rows.keys.all? { |key| key.to_s.match?(/\A\d{1,4}\z/) }
221
+ raise EnvironmentOverrides::Invalid, 'Environment rows must be a numbered list'
222
+ end
223
+
224
+ rows.sort_by { |key, _| key.to_i }.map do |_, row|
225
+ unless row.is_a?(Hash) && row.values.all?(String)
226
+ raise EnvironmentOverrides::Invalid, 'Each environment row needs a name and a value'
227
+ end
228
+
229
+ row.slice('name', 'value', 'secret')
230
+ end
231
+ end
232
+
207
233
  def parse_editable_environment(params)
208
234
  unless params['editable_env'].is_a?(String)
209
235
  raise EnvironmentOverrides::Invalid, 'Environment editor requires one editable_env field'
@@ -41,6 +41,8 @@ module Workspaces
41
41
  end
42
42
  end
43
43
 
44
+ # Removes the workspace and its saved settings and secrets. Returns the secrets that could not be
45
+ # removed (empty when all went), so callers can say so rather than report a clean removal.
44
46
  def remove(id, force: false)
45
47
  with_lock(id) do |workspace|
46
48
  raise ArgumentError, 'Workspace has uncommitted/untracked work' if !force && workspace.dirty?
@@ -48,9 +50,33 @@ module Workspaces
48
50
  Backend.new(id).stop!
49
51
  workspace.remove!(force: force)
50
52
  StateStore.new(Config.state_file).delete(id)
53
+ EnvironmentOverrides.new(id).clear!
51
54
  end
52
55
  end
53
56
 
57
+ # Deletes stored secrets whose workspace no longer exists: removed by hand, with `git worktree
58
+ # remove`, or by a purge that failed. Returns { 'removed' => [...], 'kept' => [...],
59
+ # 'problems' => [...] }.
60
+ #
61
+ # It decides a workspace is gone only from the managed directory, and refuses to guess when that
62
+ # directory cannot be read, so a missing or unmounted volume is never mistaken for "everything
63
+ # was deleted". With dry_run it reports what it would remove and changes nothing.
64
+ def sweep_orphaned_secrets(dry_run: false)
65
+ root = Config.worktrees_dir(create: false)
66
+ unless root.directory? && !root.symlink?
67
+ raise ArgumentError, 'The workspaces directory cannot be read, so nothing was removed'
68
+ end
69
+
70
+ live = root.children.select { |path| path.directory? && !path.symlink? }.map { |path| path.basename.to_s }
71
+ orphans = Vault.scopes - live
72
+ result = { 'removed' => [], 'kept' => live & Vault.scopes, 'problems' => [] }
73
+ orphans.each do |id|
74
+ problems = dry_run ? [] : Vault.purge_everywhere(id)
75
+ problems.empty? ? result['removed'] << id : result['problems'].concat(problems.map { |problem| "#{id}: #{problem}" })
76
+ end
77
+ result
78
+ end
79
+
54
80
  def update_pr(id)
55
81
  with_lock(id) do |workspace|
56
82
  result = workspace.update_from_pr!
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'json'
5
+ require 'securerandom'
6
+ require_relative '../config'
7
+
8
+ module Workspaces
9
+ module Vault
10
+ # An encrypted file per workspace under $WORKSPACES_HOME/vault, readable only with the master key
11
+ # in WORKSPACES_MASTER_KEY (32 random bytes, base64 encoded: `openssl rand -base64 32`).
12
+ #
13
+ # Each workspace gets its own AES-256-GCM key, derived from the master key with HKDF, and its id is
14
+ # bound into the ciphertext, so a file copied to another workspace's name does not decrypt. Names
15
+ # are encrypted along with values. The master key lives only in the environment of the process
16
+ # that runs `serve`; ChildEnv keeps it out of everything that process starts.
17
+ class FileVault < Provider
18
+ KEY_ENV = 'WORKSPACES_MASTER_KEY'
19
+ FORMAT_VERSION = 1
20
+ HKDF_SALT = 'workspaces-vault-v1'
21
+ GENERATE_HINT = 'openssl rand -base64 32'
22
+
23
+ def initialize(home: Config.home, env: ENV)
24
+ super()
25
+ @home = home
26
+ @env = env
27
+ end
28
+
29
+ def name
30
+ 'file'
31
+ end
32
+
33
+ def problem
34
+ text = @env.fetch(KEY_ENV, '').to_s.strip
35
+ if text.empty?
36
+ return "Secrets are turned off. Set #{KEY_ENV} where `workspaces serve` runs " \
37
+ "(generate one with: #{GENERATE_HINT})."
38
+ end
39
+ return nil if master_key
40
+
41
+ "#{KEY_ENV} must be 32 random bytes, base64 encoded (generate one with: #{GENERATE_HINT})."
42
+ end
43
+
44
+ def read(scope)
45
+ path = file_for(scope)
46
+ return {} unless path.exist?
47
+
48
+ require_usable!
49
+ decrypt(scope, path.binread)
50
+ end
51
+
52
+ def write(scope, entries)
53
+ path = file_for(scope)
54
+ return purge(scope) if entries.empty?
55
+
56
+ require_usable!
57
+ ensure_directory!
58
+ store(path, encrypt(scope, entries))
59
+ end
60
+
61
+ def purge(scope)
62
+ path = file_for(scope)
63
+ File.unlink(path) if path.exist? || path.symlink?
64
+ end
65
+
66
+ # The workspace ids that have a file here, found by name alone (no key needed). A symlinked
67
+ # or missing directory has none that can be trusted, so it lists nothing.
68
+ def scopes
69
+ return [] if directory.symlink? || !directory.directory?
70
+
71
+ directory.children.filter_map do |path|
72
+ next unless path.extname == '.enc' && !path.symlink? && path.file?
73
+
74
+ id = path.basename('.enc').to_s
75
+ id if id.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) && id.length <= 48
76
+ end.sort
77
+ end
78
+
79
+ private
80
+
81
+ def require_usable!
82
+ reason = problem
83
+ raise Unavailable, reason if reason
84
+ end
85
+
86
+ def master_key
87
+ decoded = decode64(@env.fetch(KEY_ENV, '').to_s.strip)
88
+ decoded.bytesize == 32 ? decoded : nil
89
+ rescue ArgumentError
90
+ nil
91
+ end
92
+
93
+ # Strict base64 without the base64 gem, which is no longer a default gem from Ruby 3.4.
94
+ def encode64(bytes)
95
+ [bytes].pack('m0')
96
+ end
97
+
98
+ def decode64(text)
99
+ text.unpack1('m0')
100
+ end
101
+
102
+ def key_for(scope)
103
+ OpenSSL::KDF.hkdf(master_key, salt: HKDF_SALT, info: "workspace:#{scope}", length: 32, hash: 'SHA256')
104
+ end
105
+
106
+ def encrypt(scope, entries)
107
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
108
+ cipher.key = key_for(scope)
109
+ iv = cipher.random_iv
110
+ cipher.auth_data = scope
111
+ ciphertext = cipher.update(JSON.generate(entries)) + cipher.final
112
+ JSON.generate('v' => FORMAT_VERSION, 'iv' => encode64(iv),
113
+ 'tag' => encode64(cipher.auth_tag), 'data' => encode64(ciphertext))
114
+ end
115
+
116
+ def decrypt(scope, document)
117
+ parsed = JSON.parse(document)
118
+ raise Unavailable, 'The stored secrets use an unknown format' unless parsed['v'] == FORMAT_VERSION
119
+
120
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
121
+ cipher.key = key_for(scope)
122
+ cipher.iv = decode64(parsed.fetch('iv'))
123
+ cipher.auth_tag = decode64(parsed.fetch('tag'))
124
+ cipher.auth_data = scope
125
+ entries = JSON.parse(cipher.update(decode64(parsed.fetch('data'))) + cipher.final)
126
+ raise Unavailable, 'The stored secrets are damaged' unless entries.is_a?(Hash)
127
+
128
+ entries
129
+ rescue OpenSSL::Cipher::CipherError, JSON::ParserError, KeyError, ArgumentError, TypeError
130
+ raise Unavailable, "The stored secrets could not be decrypted: wrong #{KEY_ENV}, or the file is damaged"
131
+ end
132
+
133
+ def directory
134
+ @home.join('vault')
135
+ end
136
+
137
+ def file_for(scope)
138
+ Config.validate_id!(scope.to_s)
139
+ reject_symlink!(directory)
140
+ path = directory.join("#{scope}.enc")
141
+ reject_symlink!(path)
142
+ path
143
+ end
144
+
145
+ def ensure_directory!
146
+ directory.mkpath unless directory.exist?
147
+ raise Unavailable, 'The secret vault path is not a directory' unless directory.directory?
148
+
149
+ File.chmod(0o700, directory)
150
+ end
151
+
152
+ def store(path, document)
153
+ temp = directory.join(".#{path.basename}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
154
+ begin
155
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
156
+ file.write(document)
157
+ file.flush
158
+ file.fsync
159
+ end
160
+ File.rename(temp, path)
161
+ File.chmod(0o600, path)
162
+ ensure
163
+ File.unlink(temp) if temp.exist?
164
+ end
165
+ end
166
+
167
+ def reject_symlink!(path)
168
+ raise Unavailable, 'The secret vault does not allow symlinks' if path.symlink?
169
+ end
170
+ end
171
+
172
+ register('file', FileVault)
173
+ end
174
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Workspaces
4
+ # Where write-only secrets live. The dashboard can put a secret in and a workspace's steps can read it
5
+ # back out, but nothing here ever hands a value to the interface.
6
+ #
7
+ # A provider stores one set of name => value strings per scope (a workspace id) and is chosen with
8
+ # WORKSPACES_VAULT (default "file"). Register another with Vault.register(name, ProviderClass). The
9
+ # contract is small on purpose so that a remote store fits behind it:
10
+ #
11
+ # available? / problem whether it can be used, and if not, a plain-language reason that never
12
+ # contains key material
13
+ # read(scope) the whole set, {} when there is none; raises Unavailable when a set exists
14
+ # but cannot be read
15
+ # write(scope, entries) replaces the whole set; an empty hash removes it
16
+ # purge(scope) removes the set (used when a workspace is removed)
17
+ module Vault
18
+ class Error < StandardError; end
19
+ class Unavailable < Error; end
20
+
21
+ DEFAULT_PROVIDER = 'file'
22
+
23
+ # The shape every provider follows. Providers inherit it so a missing method fails loudly.
24
+ class Provider
25
+ def name
26
+ raise NotImplementedError, "#{self.class} must name itself"
27
+ end
28
+
29
+ def problem
30
+ nil
31
+ end
32
+
33
+ def available?
34
+ problem.nil?
35
+ end
36
+
37
+ def read(_scope)
38
+ raise NotImplementedError, "#{self.class} must implement read"
39
+ end
40
+
41
+ def write(_scope, _entries)
42
+ raise NotImplementedError, "#{self.class} must implement write"
43
+ end
44
+
45
+ def purge(_scope)
46
+ raise NotImplementedError, "#{self.class} must implement purge"
47
+ end
48
+
49
+ # Every scope this provider holds secrets for. Used to find the ones whose workspace is gone, so
50
+ # a provider that cannot list its scopes returns nil and is simply never swept.
51
+ def scopes
52
+ nil
53
+ end
54
+
55
+ def status
56
+ { 'provider' => name, 'available' => available?, 'problem' => problem }
57
+ end
58
+ end
59
+
60
+ # Stands in for a provider name nobody registered, so a typo shows up as a clear message in the
61
+ # dashboard rather than an exception while rendering it.
62
+ class Unknown < Provider
63
+ def initialize(requested)
64
+ super()
65
+ @requested = requested.to_s[0, 40]
66
+ end
67
+
68
+ def name
69
+ @requested
70
+ end
71
+
72
+ def problem
73
+ "Unknown secret vault provider #{@requested.inspect}"
74
+ end
75
+
76
+ # Nothing can be stored under an unknown provider, so there is nothing to read or remove; a typo
77
+ # in WORKSPACES_VAULT must not stop workspaces that have no secrets from running.
78
+ def read(_scope)
79
+ {}
80
+ end
81
+
82
+ def write(_scope, _entries)
83
+ raise Unavailable, problem
84
+ end
85
+
86
+ def purge(_scope)
87
+ nil
88
+ end
89
+
90
+ def scopes
91
+ nil
92
+ end
93
+ end
94
+
95
+ @providers = {}
96
+
97
+ class << self
98
+ def register(name, provider_class)
99
+ @providers[name.to_s] = provider_class
100
+ end
101
+
102
+ def provider(name = ENV.fetch('WORKSPACES_VAULT', nil))
103
+ requested = name.to_s.strip
104
+ requested = DEFAULT_PROVIDER if requested.empty?
105
+ klass = @providers[requested]
106
+ klass ? klass.new : Unknown.new(requested)
107
+ end
108
+
109
+ def status
110
+ provider.status
111
+ end
112
+
113
+ # Removes a workspace's secrets, whatever WORKSPACES_VAULT says now. The built-in file vault is
114
+ # always asked, because its files stay on disk when the setting is changed or mistyped; the
115
+ # configured provider is asked as well when it is a different one. Returns the problems it
116
+ # could not clear, as plain sentences with no key material, so a caller can say so instead of
117
+ # reporting a removal that left secrets behind.
118
+ # Scopes held by the built-in file vault and the configured provider, for finding orphans.
119
+ def scopes
120
+ [@providers.fetch(DEFAULT_PROVIDER).new, provider].uniq(&:name).flat_map { |vault| vault.scopes || [] }.uniq.sort
121
+ rescue StandardError
122
+ []
123
+ end
124
+
125
+ def purge_everywhere(scope)
126
+ providers = [@providers.fetch(DEFAULT_PROVIDER).new, provider]
127
+ providers.uniq(&:name).each_with_object([]) do |vault, problems|
128
+ vault.purge(scope)
129
+ rescue StandardError => e
130
+ problems << "#{vault.name}: #{e.message}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ require_relative 'vault/file_vault'
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.17'
2
+ VERSION = '0.1.18'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.17
4
+ version: 0.1.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Workspaces contributors
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-23 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -102,6 +102,8 @@ files:
102
102
  - lib/workspaces/step_log.rb
103
103
  - lib/workspaces/tee_log.rb
104
104
  - lib/workspaces/tls.rb
105
+ - lib/workspaces/vault.rb
106
+ - lib/workspaces/vault/file_vault.rb
105
107
  - lib/workspaces/version.rb
106
108
  - lib/workspaces/workflow.rb
107
109
  - lib/workspaces/worktree.rb