workspaces 0.1.0 → 0.1.1

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: 431b59c0d2a5bef3dc7a2456416d865992ac05664b371bbf6245bb7d5ae3a3b4
4
- data.tar.gz: 88526e06c47837b47e6b507df9ecaf48cd4656365427cf9a7922bcf3fefcff52
3
+ metadata.gz: e22bcc91e0c7874f565156a5071aa07dae75e52eda5c9b5555f8d4a878f39aec
4
+ data.tar.gz: c72328bf47eb7aa1573221224cdc28f4f1e5f24e9539bf23adf8bfc970da7047
5
5
  SHA512:
6
- metadata.gz: 3ce730d1d8d81bfd3e2f997c867623b3f8936b6861a441abec837e38b3ab6098371c0a1e800e8934eccfa651af0f33c81d93e7d0764543bacadb48df3f5cd6b2
7
- data.tar.gz: 489216342c60ff24c9455c41e43cc05753f396c18ac690f051cada947c01b06d97af40123b75bf639cb51d394499e90d20cc37a69884f70bedbbe1ecc0baa9bb
6
+ metadata.gz: 58edd2f1a515d3ee18a69bfe2fe600ef2b26799689e35d94b57ab87251f634864a9e8a7d12415bdf1c5608dc461480dc38caa29c9f7db4305f62685ce739ac3e
7
+ data.tar.gz: d0cd9666cbdb265c23719d2de5aec54a6215b6db2f986ecbe061c27aae6578de5a8145a81669a382322ebc2618d3481edb947599d9edff7d76a68193b247e5c1
data/DESIGN.md CHANGED
@@ -52,6 +52,7 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
52
52
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
53
53
  - **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
54
54
  - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
55
+ - **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
55
56
 
56
57
  ## Accessibility constraints
57
58
 
@@ -35,6 +35,15 @@ module Workspaces
35
35
  })
36
36
  end
37
37
 
38
+ def replace_editable_environment(id, text:)
39
+ restart = Backend.new(id).running?
40
+ launch(id, restart: restart, force: true,
41
+ prelaunch: lambda { |_workspace|
42
+ defaults = Recipe.new.default_editable_env
43
+ EnvironmentOverrides.new(id).replace_editable(text, defaults: defaults)
44
+ })
45
+ end
46
+
38
47
  def wait(id)
39
48
  workers_mutex.synchronize { workers[id] }&.value
40
49
  snapshot(id)
@@ -51,7 +60,6 @@ module Workspaces
51
60
  data.merge('presentation' => Presentation.new(data).to_h)
52
61
  end
53
62
 
54
-
55
63
  private
56
64
 
57
65
  def resolve_status(id, status, busy)
@@ -71,7 +79,8 @@ module Workspaces
71
79
  'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
72
80
  'steps' => entry.fetch('steps', []),
73
81
  'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
74
- 'environment_keys' => environment_keys(id)
82
+ 'environment_keys' => environment_keys(id),
83
+ 'editable_environment' => editable_environment(id)
75
84
  }
76
85
  end
77
86
 
@@ -192,6 +201,12 @@ module Workspaces
192
201
  []
193
202
  end
194
203
 
204
+ def editable_environment(id)
205
+ EnvironmentOverrides.new(id).editable_values(Recipe.new.default_editable_env)
206
+ rescue Recipe::Invalid, EnvironmentOverrides::Invalid
207
+ {}
208
+ end
209
+
195
210
  def update(id, attributes)
196
211
  @state.set(id, attributes.merge('updated_at' => now))
197
212
  end
@@ -2,18 +2,12 @@
2
2
  'use strict';
3
3
  const form = document.getElementById('environment-form');
4
4
  if (!form) return;
5
- const list = form.querySelector('[data-env-add-list]');
6
- const addButton = form.querySelector('[data-env-add-row]');
7
5
  const status = document.getElementById('environment-status');
8
- const namePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
- let rowIndex = 0;
10
6
 
11
7
  function toggleBusy(nextBusy) {
12
- form
13
- .querySelectorAll('fieldset, [data-env-submit], [data-env-add-row]')
14
- .forEach((control) => {
15
- control.disabled = nextBusy;
16
- });
8
+ form.querySelectorAll('fieldset, [data-env-submit]').forEach((control) => {
9
+ control.disabled = nextBusy;
10
+ });
17
11
  }
18
12
 
19
13
  function updateStatus(busy) {
@@ -22,98 +16,8 @@
22
16
  'Workspace is busy. Environment updates are temporarily disabled.';
23
17
  }
24
18
 
25
- function syncReplace(toggle) {
26
- const input = toggle
27
- .closest('[data-env-existing]')
28
- .querySelector('[data-replace-value]');
29
- input.disabled = !toggle.checked;
30
- if (!toggle.checked) input.value = '';
31
- }
32
-
33
- function labelledInput(id, label, type) {
34
- const wrap = document.createElement('p');
35
- const caption = document.createElement('label');
36
- caption.className = 'env-input-label';
37
- caption.htmlFor = id;
38
- caption.textContent = label;
39
- const input = document.createElement('input');
40
- input.id = id;
41
- input.type = type;
42
- input.autocomplete = 'off';
43
- wrap.append(caption, input);
44
- return [wrap, input];
45
- }
46
-
47
- function addVariableRow() {
48
- const row = document.createElement('li');
49
- row.className = 'env-add-item';
50
- row.dataset.envAddRow = String(rowIndex);
51
- const [keyWrap, key] = labelledInput(
52
- 'env-add-key-' + rowIndex,
53
- 'Variable name',
54
- 'text'
55
- );
56
- key.spellcheck = false;
57
- key.dataset.envNewName = 'true';
58
- key.pattern = '[A-Za-z_][A-Za-z0-9_]*';
59
- key.title =
60
- 'Use letters, numbers, and underscores; must not start with a number.';
61
- const [valueWrap, value] = labelledInput(
62
- 'env-add-value-' + rowIndex,
63
- 'Value',
64
- 'password'
65
- );
66
- value.dataset.envNewValue = 'true';
67
- rowIndex += 1;
68
- const fields = document.createElement('div');
69
- fields.className = 'env-add-grid';
70
- fields.append(keyWrap, valueWrap);
71
- const actions = document.createElement('div');
72
- actions.className = 'env-row-actions';
73
- const remove = document.createElement('button');
74
- remove.type = 'button';
75
- remove.className = 'button button--subtle button--icon';
76
- remove.textContent = 'Remove';
77
- remove.setAttribute('aria-label', 'Remove variable row');
78
- remove.addEventListener('click', () => row.remove());
79
- actions.append(remove);
80
- row.append(fields, actions);
81
- list.append(row);
82
- key.focus();
83
- }
84
-
85
- function appendSetValue(row) {
86
- const key = row.querySelector('[data-env-new-name]').value.trim();
87
- const value = row.querySelector('[data-env-new-value]');
88
- if (key && namePattern.test(key)) {
89
- const hidden = document.createElement('input');
90
- hidden.type = 'hidden';
91
- hidden.dataset.generatedSet = 'true';
92
- hidden.name = 'set[' + key + ']';
93
- hidden.value = value.value;
94
- form.append(hidden);
95
- }
96
- }
97
-
98
- function prepareSubmit() {
99
- form.querySelectorAll('[data-env-existing]').forEach((row) => {
100
- const toggle = row.querySelector('[data-replace-toggle]');
101
- if (!toggle.checked) row.querySelector('[data-replace-value]').value = '';
102
- });
103
- form
104
- .querySelectorAll('[data-generated-set]')
105
- .forEach((input) => input.remove());
106
- form.querySelectorAll('li[data-env-add-row]').forEach(appendSetValue);
107
- }
108
-
109
- form.querySelectorAll('[data-replace-toggle]').forEach((toggle) => {
110
- toggle.addEventListener('change', () => syncReplace(toggle));
111
- syncReplace(toggle);
112
- });
113
- addButton.addEventListener('click', addVariableRow);
114
19
  form.addEventListener('submit', () => {
115
- prepareSubmit();
116
- status.textContent = 'Submitting environment overrides…';
20
+ status.textContent = 'Submitting environment settings…';
117
21
  });
118
22
  window.WorkspaceEnvironment = { toggleBusy, updateStatus };
119
23
  toggleBusy(document.getElementById('workspace').dataset.busy === 'true');
@@ -26,6 +26,7 @@
26
26
  [hidden] { display: none !important; }
27
27
  input { max-width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--space-1); }
28
28
  input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
29
+ textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
29
30
  summary { cursor: pointer; color: var(--muted); }
30
31
  summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
31
32
  .workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-block-start: var(--space-4); }
@@ -225,38 +226,6 @@ h1 {
225
226
  padding-inline: var(--space-2);
226
227
  }
227
228
 
228
- .env-existing-list,
229
- .env-add-list {
230
- list-style: none;
231
- margin: 0;
232
- padding: 0;
233
- display: grid;
234
- gap: var(--space-4);
235
- }
236
-
237
- .env-existing-item,
238
- .env-add-item {
239
- border: 1px solid var(--border);
240
- border-radius: 10px;
241
- padding: var(--space-3);
242
- background: color-mix(in oklab, var(--bg) 65%, transparent);
243
- display: grid;
244
- gap: var(--space-3);
245
- }
246
-
247
- .env-existing-name,
248
- .env-add-grid {
249
- display: grid;
250
- gap: var(--space-2);
251
- }
252
-
253
- .env-existing-actions {
254
- display: flex;
255
- flex-wrap: wrap;
256
- gap: var(--space-4);
257
- }
258
-
259
- .env-inline-label,
260
229
  .env-input-label {
261
230
  display: inline-flex;
262
231
  align-items: center;
@@ -269,22 +238,20 @@ h1 {
269
238
  margin-block-end: var(--space-1);
270
239
  }
271
240
 
272
- .env-add-grid {
273
- grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
274
- }
275
-
276
- .env-add-grid p {
277
- margin: 0;
278
- }
279
-
280
- .env-row-actions {
281
- display: flex;
282
- justify-content: flex-end;
283
- }
284
-
285
- .button--icon {
286
- min-inline-size: 2.25rem;
287
- padding-inline: var(--space-2);
241
+ .env-fieldset textarea {
242
+ display: block;
243
+ inline-size: 100%;
244
+ min-block-size: 12rem;
245
+ resize: vertical;
246
+ padding: var(--space-3);
247
+ color: var(--text);
248
+ background: var(--bg);
249
+ border: 1px solid var(--border);
250
+ border-radius: var(--space-1);
251
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
252
+ line-height: 1.5;
253
+ white-space: pre;
254
+ overflow: auto;
288
255
  }
289
256
 
290
257
  .env-actions {
@@ -32,44 +32,15 @@
32
32
  <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
33
33
  </section>
34
34
  <section class="status-panel env-panel" aria-labelledby="environment-overrides">
35
- <h2 id="environment-overrides">Environment overrides</h2>
36
- <p class="muted">Set workspace-only environment overrides for this managed workspace. Saved values are write-only and never shown here. Remove an override to restore recipe defaults.</p>
37
- <p class="muted">Protected runner settings are rejected by the backend and cannot be overridden here.</p>
35
+ <h2 id="environment-overrides">Editable environment</h2>
36
+ <p class="muted">One NAME=value per line. These non-secret settings start from <code>default_editable_env</code> in the trusted recipe. Edit values to customize this workspace; remove a line to restore its default.</p>
37
+ <p class="muted">Do not enter secrets: editor values appear in the dashboard and status API. Add other non-secret names as needed. Previously saved values outside the editor remain hidden and unchanged.</p>
38
38
  <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form>
39
39
  <fieldset class="env-fieldset" data-environment-fieldset>
40
- <legend class="env-legend">Existing overrides</legend>
41
- <% if environment_keys.empty? %>
42
- <p class="muted">No workspace-specific overrides are currently saved.</p>
43
- <% else %>
44
- <ul class="env-existing-list">
45
- <% environment_keys.each_with_index do |name, index| %>
46
- <% key_id = "env-existing-#{index}" %>
47
- <li class="env-existing-item" data-env-existing>
48
- <div class="env-existing-name">
49
- <label for="<%= key_id %>-replace-value"><code><%= escape(name) %></code></label>
50
- <p class="muted">Saved value: <span aria-hidden="true">••••••••</span><span class="visually-hidden">Saved value masked</span></p>
51
- </div>
52
- <div class="env-existing-actions">
53
- <label class="env-inline-label" for="<%= key_id %>-replace-toggle">
54
- <input type="checkbox" id="<%= key_id %>-replace-toggle" data-replace-toggle aria-controls="<%= key_id %>-replace-value">
55
- Replace
56
- </label>
57
- <label class="env-inline-label" for="<%= key_id %>-remove">
58
- <input type="checkbox" id="<%= key_id %>-remove" name="remove[]" value="<%= escape(name) %>">
59
- Remove
60
- </label>
61
- </div>
62
- <label class="env-input-label" for="<%= key_id %>-replace-value">New value for <code><%= escape(name) %></code></label>
63
- <input type="password" id="<%= key_id %>-replace-value" name="set[<%= escape(name) %>]" autocomplete="off" data-replace-value disabled>
64
- </li>
65
- <% end %>
66
- </ul>
67
- <% end %>
68
- </fieldset>
69
- <fieldset class="env-fieldset" data-environment-additions>
70
- <legend class="env-legend">Add variables</legend>
71
- <div class="env-add-list" data-env-add-list></div>
72
- <button type="button" class="button button--subtle" data-env-add-row>Add variable</button>
40
+ <legend class="env-legend">Workspace settings</legend>
41
+ <label class="env-input-label" for="editable-env">Environment variables</label>
42
+ <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off" aria-describedby="editable-env-help"><%= escape(editable_environment_text) %></textarea>
43
+ <p class="muted" id="editable-env-help">Values may contain =. Blank lines are ignored. Names are case-sensitive and cannot repeat.</p>
73
44
  </fieldset>
74
45
  <div class="env-actions">
75
46
  <button class="button button--primary" data-env-submit>Save &amp; restart</button>
@@ -87,6 +87,13 @@ module Workspaces
87
87
  keys.map(&:to_s).sort
88
88
  end
89
89
 
90
+ def editable_environment_text
91
+ values = @snapshot['editable_environment']
92
+ return '' unless values.is_a?(Hash)
93
+
94
+ values.sort.map { |name, value| "#{name}=#{value}" }.join("\n")
95
+ end
96
+
90
97
  def action_form(action)
91
98
  style = action['primary'] ? 'button--primary' : 'button--subtle'
92
99
  "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
@@ -14,31 +14,69 @@ module Workspaces
14
14
  MAX_VALUE_BYTES = 8192
15
15
  MAX_BODY_BYTES = 128 * 1024
16
16
 
17
+ def self.validate_entries!(entries)
18
+ raise Invalid, 'Environment overrides must be a mapping' unless entries.is_a?(Hash)
19
+ raise Invalid, 'Environment overrides are limited to 100 entries' if entries.length > MAX_ENTRIES
20
+
21
+ entries.each_with_object({}) do |(name, value), normalized|
22
+ validate_name!(name)
23
+ validate_value!(value)
24
+ normalized[name] = value
25
+ end
26
+ end
27
+
28
+ def self.validate_name!(name)
29
+ unless name.is_a?(String) && name.match?(NAME_PATTERN)
30
+ raise Invalid, 'Environment names must match /^[A-Za-z_][A-Za-z0-9_]*$/'
31
+ end
32
+ return unless BLOCKED_NAMES.include?(name) || BLOCKED_PREFIXES.any? { |prefix| name.start_with?(prefix) }
33
+
34
+ raise Invalid, 'Environment name is reserved for the workspace runner'
35
+ end
36
+
37
+ def self.validate_value!(value)
38
+ raise Invalid, 'Environment values must be strings' unless value.is_a?(String)
39
+ raise Invalid, 'Environment values must not contain NUL bytes' if value.include?("\0")
40
+ raise Invalid, 'Environment values must be 8192 bytes or smaller' if value.bytesize > MAX_VALUE_BYTES
41
+ end
42
+
17
43
  def initialize(workspace_id, home: Config.home)
18
44
  @workspace_id = Config.validate_id!(workspace_id.to_s)
19
45
  @home = home
20
46
  end
21
47
 
22
48
  def load
23
- reject_symlink!(directory_path)
24
- reject_symlink!(file_path)
25
- return {} unless file_path.exist?
26
-
27
- parsed = JSON.parse(file_path.read)
28
- raise Invalid, 'Stored environment overrides are invalid' unless parsed.is_a?(Hash)
29
-
30
- validate_entries!(parsed)
31
- rescue JSON::ParserError
32
- raise Invalid, 'Stored environment overrides are invalid'
49
+ load_file(file_path).merge(load_file(editable_file_path))
33
50
  end
34
51
 
35
52
  def keys
36
53
  load.keys.sort
37
54
  end
38
55
 
56
+ def editable_values(defaults)
57
+ load_file(file_path)
58
+ defaults.merge(load_file(editable_file_path).reject { |name, _| sensitive_name?(name) })
59
+ end
60
+
61
+ def replace_editable(text, defaults:)
62
+ raise Invalid, 'Environment editor must contain text' unless text.is_a?(String)
63
+ raise Invalid, 'Environment update payload is too large' if text.bytesize > MAX_BODY_BYTES
64
+ self.class.validate_entries!(defaults)
65
+
66
+ entries = parse_lines(text)
67
+ self.class.validate_entries!(entries)
68
+
69
+ updated = entries.reject { |name, value| defaults[name] == value }
70
+ if updated.keys.any? { |name| sensitive_name?(name) }
71
+ raise Invalid, 'Secret-like environment names cannot be stored in the visible editor'
72
+ end
73
+ persist_editable(entries, updated, defaults)
74
+ load
75
+ end
76
+
39
77
  def apply_patch(set:, remove:)
40
78
  validate_patch!(set, remove)
41
- updated = load.merge(set)
79
+ updated = load_file(file_path).merge(set)
42
80
  remove.each { |name| updated.delete(name) }
43
81
  validate_entries!(updated)
44
82
  persist(updated)
@@ -47,6 +85,7 @@ module Workspaces
47
85
 
48
86
  def clear!
49
87
  delete_file!
88
+ delete_file!(editable_file_path)
50
89
  end
51
90
 
52
91
  def scrub(value)
@@ -61,6 +100,35 @@ module Workspaces
61
100
 
62
101
  attr_reader :workspace_id, :home
63
102
 
103
+ def sensitive_name?(name)
104
+ name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
105
+ end
106
+
107
+ def parse_lines(text)
108
+ entries = {}
109
+ text.each_line.with_index(1) do |line, number|
110
+ line = line.delete_suffix("\n").delete_suffix("\r")
111
+ next if line.strip.empty?
112
+
113
+ name, separator, value = line.partition('=')
114
+ raise Invalid, "Line #{number} must be NAME=value" if separator.empty? || name != name.strip
115
+ raise Invalid, "Line #{number} repeats #{name}" if entries.key?(name)
116
+ raise Invalid, "Line #{number}: values must not contain carriage returns" if value.include?("\r")
117
+
118
+ entries[name] = value
119
+ end
120
+ entries
121
+ end
122
+
123
+ def persist_editable(entries, updated, defaults)
124
+ legacy = load_file(file_path)
125
+ load_file(editable_file_path)
126
+ kept = legacy.reject { |name, _| defaults.key?(name) || entries.key?(name) }
127
+ validate_entries!(kept.merge(updated))
128
+ persist(kept)
129
+ persist(updated, path: editable_file_path)
130
+ end
131
+
64
132
  def directory_path
65
133
  home.join('environment')
66
134
  end
@@ -69,6 +137,21 @@ module Workspaces
69
137
  directory_path.join("#{workspace_id}.json")
70
138
  end
71
139
 
140
+ def editable_file_path
141
+ directory_path.join("#{workspace_id}.editable.json")
142
+ end
143
+
144
+ def load_file(path)
145
+ reject_symlink!(directory_path)
146
+ reject_symlink!(path)
147
+ return {} unless path.exist?
148
+
149
+ parsed = JSON.parse(path.read)
150
+ validate_entries!(parsed)
151
+ rescue JSON::ParserError
152
+ raise Invalid, 'Stored environment overrides are invalid'
153
+ end
154
+
72
155
  def validate_patch!(set, remove)
73
156
  set_names = validate_set!(set)
74
157
  remove_names = validate_remove!(remove)
@@ -95,45 +178,27 @@ module Workspaces
95
178
  end
96
179
 
97
180
  def validate_entries!(entries)
98
- raise Invalid, 'Stored environment overrides are invalid' unless entries.is_a?(Hash)
99
- raise Invalid, 'Environment overrides are limited to 100 entries' if entries.length > MAX_ENTRIES
100
-
101
- entries.each_with_object({}) do |(name, value), normalized|
102
- validate_name!(name)
103
- validate_value!(value)
104
- normalized[name] = value
105
- end
181
+ self.class.validate_entries!(entries)
106
182
  end
107
183
 
108
184
  def validate_name!(name)
109
- unless name.is_a?(String) && name.match?(NAME_PATTERN)
110
- raise Invalid, 'Environment names must match /^[A-Za-z_][A-Za-z0-9_]*$/'
111
- end
112
- raise Invalid, 'Environment name is reserved for the workspace runner' if reserved_name?(name)
185
+ self.class.validate_name!(name)
113
186
  end
114
187
 
115
188
  def validate_value!(value)
116
- raise Invalid, 'Environment values must be strings' unless value.is_a?(String)
117
- raise Invalid, 'Environment values must not contain NUL bytes' if value.include?("\0")
118
- return if value.bytesize <= MAX_VALUE_BYTES
119
-
120
- raise Invalid, 'Environment values must be 8192 bytes or smaller'
121
- end
122
-
123
- def reserved_name?(name)
124
- BLOCKED_NAMES.include?(name) || BLOCKED_PREFIXES.any? { |prefix| name.start_with?(prefix) }
189
+ self.class.validate_value!(value)
125
190
  end
126
191
 
127
- def persist(entries)
192
+ def persist(entries, path: file_path)
128
193
  ensure_directory!
129
- reject_symlink!(file_path)
130
- return delete_file! if entries.empty?
194
+ reject_symlink!(path)
195
+ return delete_file!(path) if entries.empty?
131
196
 
132
197
  temp_path = directory_path.join(".#{workspace_id}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
133
198
  begin
134
199
  write_temp_file(temp_path, entries)
135
- File.rename(temp_path, file_path)
136
- File.chmod(0o600, file_path)
200
+ File.rename(temp_path, path)
201
+ File.chmod(0o600, path)
137
202
  ensure
138
203
  File.unlink(temp_path) if temp_path.exist?
139
204
  end
@@ -147,11 +212,11 @@ module Workspaces
147
212
  end
148
213
  end
149
214
 
150
- def delete_file!
151
- return unless file_path.exist?
215
+ def delete_file!(path = file_path)
216
+ return unless path.exist? || path.symlink?
152
217
 
153
- reject_symlink!(file_path)
154
- File.unlink(file_path)
218
+ reject_symlink!(path)
219
+ File.unlink(path)
155
220
  end
156
221
 
157
222
  def ensure_directory!
@@ -106,7 +106,12 @@ module Workspaces
106
106
  return response(405, 'Use POST') unless request.post?
107
107
  return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
108
108
 
109
- updated = @coordinator.update_environment(id, **parse_environment_changes(request))
109
+ params = environment_params(request)
110
+ updated = if params.key?('editable_env')
111
+ @coordinator.replace_editable_environment(id, text: parse_editable_environment(params))
112
+ else
113
+ @coordinator.update_environment(id, **parse_environment_changes(params))
114
+ end
110
115
  return response(409, 'Workspace is busy preparing, restarting, or running a command') unless updated
111
116
 
112
117
  redirect(Config.dashboard_url(id))
@@ -132,13 +137,26 @@ module Workspaces
132
137
  redirect(Config.dashboard_url(workspace.id))
133
138
  end
134
139
 
135
- def parse_environment_changes(request)
140
+ def environment_params(request)
136
141
  validate_environment_content_type!(request)
137
142
  raw = environment_body(request)
138
- params = Rack::Utils.parse_nested_query(raw)
143
+ Rack::Utils.parse_nested_query(raw)
144
+ rescue Rack::Utils::ParameterTypeError => e
145
+ raise EnvironmentOverrides::Invalid, "Invalid environment form: #{e.class}"
146
+ end
147
+
148
+ def parse_environment_changes(params)
139
149
  { set: normalize_set(params['set']), remove: normalize_remove(params['remove']) }
140
150
  end
141
151
 
152
+ def parse_editable_environment(params)
153
+ unless params['editable_env'].is_a?(String)
154
+ raise EnvironmentOverrides::Invalid, 'Environment editor requires one editable_env field'
155
+ end
156
+
157
+ params.fetch('editable_env')
158
+ end
159
+
142
160
  def validate_environment_content_type!(request)
143
161
  return if ['', 'application/x-www-form-urlencoded'].include?(request.media_type.to_s)
144
162
 
@@ -1,19 +1,22 @@
1
1
  require 'yaml'
2
2
  require_relative 'config'
3
+ require_relative 'environment_overrides'
3
4
 
4
5
  module Workspaces
5
6
  class Recipe
6
7
  class Invalid < ArgumentError; end
7
8
  RESERVED_ENV = %w(WORKSPACE_ID WORKSPACE_PATH WORKSPACE_REPO_ROOT WORKSPACE_PORT WORKSPACE_URL
8
9
  WORKSPACE_HOST WORKSPACE_CACHE_DIR WORKSPACE_RUN_ID).freeze
9
- attr_reader :steps, :environment
10
+ attr_reader :steps, :environment, :default_editable_env
10
11
 
11
12
  def initialize(root: Config.repo_root)
12
13
  @root = root
13
14
  document = YAML.safe_load(root.join('.workspaces.yml').read, aliases: false)
14
15
  validate_document!(document)
16
+ @default_editable_env = validate_editable_env!(document.fetch('default_editable_env', {}))
15
17
  @environment = validate_env!(document.fetch('env', {}))
16
18
  @steps = document.fetch('steps').map { |step| validate_step!(step) }
19
+ validate_editable_overlap!
17
20
  validate_sequence!
18
21
  rescue Errno::ENOENT
19
22
  raise Invalid, 'Add .workspaces.yml to the source repository before starting a workspace'
@@ -33,7 +36,7 @@ module Workspaces
33
36
  end
34
37
 
35
38
  def env(step, context, overrides: {})
36
- expanded = environment.merge(step.fetch('env', {})).transform_values do |value|
39
+ expanded = environment.merge(step.fetch('env', {})).merge(default_editable_env).transform_values do |value|
37
40
  value.nil? ? nil : expand(value, context)
38
41
  end
39
42
  expanded.merge(overrides).merge(context)
@@ -46,7 +49,7 @@ module Workspaces
46
49
  raise Invalid,
47
50
  'Configuration must be a mapping with version: 1'
48
51
  end
49
- unknown_keys!(document, %w(version env steps))
52
+ unknown_keys!(document, %w(version env default_editable_env steps))
50
53
  raise Invalid, 'steps must be a nonempty array' unless document['steps'].is_a?(Array) && !document['steps'].empty?
51
54
  end
52
55
 
@@ -106,6 +109,29 @@ module Workspaces
106
109
  values
107
110
  end
108
111
 
112
+ def validate_editable_env!(values)
113
+ raise Invalid, 'default_editable_env must be a mapping' unless values.is_a?(Hash)
114
+
115
+ EnvironmentOverrides.validate_entries!(values)
116
+ values.each do |key, value|
117
+ raise Invalid, "#{key} is supplied by the workspace runner" if RESERVED_ENV.include?(key)
118
+ if key.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
119
+ raise Invalid, 'Secret-like names cannot be declared in default_editable_env'
120
+ end
121
+ raise Invalid, 'Editable environment values must be single-line strings' if value.match?(/[\r\n]/)
122
+ end
123
+ values
124
+ rescue EnvironmentOverrides::Invalid => e
125
+ raise Invalid, e.message
126
+ end
127
+
128
+ def validate_editable_overlap!
129
+ names = default_editable_env.keys
130
+ return unless environment.keys.intersect?(names) || steps.any? { |step| step['env'].keys.intersect?(names) }
131
+
132
+ raise Invalid, 'default_editable_env names cannot also appear in env or step env'
133
+ end
134
+
109
135
  def validate_env_name!(key)
110
136
  return if key.is_a?(String) && key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
111
137
 
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.0'
2
+ VERSION = '0.1.1'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Workspaces contributors