start-command 0.39.0 → 0.39.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # start-command
2
2
 
3
+ ## 0.39.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 328fa0c: Sanitize `--upload-log` logs and auto-reported issue text with `@link-foundation/sensitive-data-sanitizer` (#250) instead of start-command's own rules, which removed content that is not secret (#249). Before, `Authorization:` was masked to the end of the line, which dropped the rest of one-line JSON request bodies. Every URL with a port was masked, and `sk-` matched inside words such as `disk-baseline-`. Now only credentials are masked (`--credentials-only`): values of credential-named environment variables, the gh `hosts.yml` tokens, and `Authorization` schemes with their token. The rest of the line, account details and URLs with ports stay. The log streams through the library CLI from stdin into a private `0600` copy, and invalid UTF-8 bytes are kept. Any failure blocks the upload with a value-free code such as `Log sanitization failed (ERR_BINARY); upload blocked.` `START_SENSITIVE_DATA_SANITIZER` selects another CLI, and `--no-sanitize` is unchanged.
8
+
9
+ ## 0.39.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 55c57b0: Stop the Docker cgroup sampler when the counters can never be read (#247). On hosts whose containers have no `memory.events` (the `memory` controller is not delegated, e.g. a root `cgroup.subtree_control` of `cpuset cpu pids`) and whose local cgroup is hidden, the sampler started one `docker exec` and one `docker inspect -f {{.State.Running}}` every interval for the whole life of the task without ever recording a sample. Exit 3 (unreadable counters) from the long-lived stream or from the per-round read now records the reason and stops sampling, as a missing `sh` (exit 126/127) already did; the post-mortem reports `Memory: unavailable (local cgroup unavailable; Docker exec exit=3: private cgroup v2 counters unreadable)` instead of the last round's error. Other failed rounds are still retried, but each one doubles the wait, up to 60 s (or the interval, if longer); a sample restores the interval. The completion watcher (`docker wait`) still finishes the run.
14
+
15
+ Attached `--isolated screen` runs no longer report a failed command as exit 0 when `screen -dmS` returns before `screen -ls` lists the new session. A session missing from `screen -ls` now counts as finished only once its exit-code file is written, or after 20 polls (about 2 s) without the session.
16
+
3
17
  ## 0.39.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -41,7 +41,8 @@ checked examples.
41
41
 
42
42
  `-h` also prints usage. Labels persist across Docker resumes. New detached
43
43
  containers support copy-free command handoff; legacy snapshots require capacity
44
- preflight. Log uploads are private and sanitized; manual `--no-sanitize` opts out.
44
+ preflight. Log uploads are private and sanitized with the bundled
45
+ `@link-foundation/sensitive-data-sanitizer`; manual `--no-sanitize` opts out.
45
46
 
46
47
  Snapshot resumes take `--snapshot-wait`, `--snapshot-disk-wait`,
47
48
  `--remove-original-after`, `--snapshot-size-hint` and `--snapshot-prepare`, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -68,6 +68,7 @@
68
68
  ]
69
69
  },
70
70
  "dependencies": {
71
+ "@link-foundation/sensitive-data-sanitizer": "^0.12.0",
71
72
  "command-stream": "^0.9.4",
72
73
  "lino-objects-codec": "^0.4.0"
73
74
  },
@@ -30,6 +30,15 @@ const { shellQuote } = require('./isolation-log-utils');
30
30
  /** Default seconds between samples; the watcher environment may override it. */
31
31
  const CGROUP_SAMPLE_INTERVAL_SECONDS = 5;
32
32
 
33
+ /**
34
+ * Longest wait between rounds that keep failing (issue #247), unless the
35
+ * interval itself is longer. Each round without a sample doubles the wait.
36
+ */
37
+ const CGROUP_SAMPLE_BACKOFF_MAX_SECONDS = 60;
38
+
39
+ /** Exit status of both in-container reads for unreadable counters. */
40
+ const CGROUP_UNREADABLE_EXIT = 3;
41
+
33
42
  /** Shell variables used by the sampler snippets. */
34
43
  const CGROUP_SHELL_VARS = {
35
44
  /** Last sample: `<memory.max> <memory.peak> <oom> <oom_kill>`. */
@@ -80,17 +89,18 @@ function buildCgroupFunctionsSnippet() {
80
89
  * (`docker exec -i`), so it runs only shell builtins: the task's own CPU
81
90
  * limit pays for no `cat`, `sed` or `sleep` processes. Stdin reaches EOF when
82
91
  * the exec's client is gone, so the loop never outlives the sampler; dockerd
83
- * would accept its output even then. Exit 3: unreadable counters.
92
+ * would accept its output even then. Exit 3: unreadable counters (no
93
+ * delegated `memory` controller), which no later round can change.
84
94
  * @returns {string} Shell script for `sh -c`
85
95
  */
86
96
  function buildCgroupStreamScript() {
87
97
  return [
88
98
  'while read -r __scx_tick; do __scx_oom=; __scx_kill=; __scx_max=-; __scx_peak=-',
89
- '[ -r "$1/memory.events" ] || exit 3',
99
+ `[ -r "$1/memory.events" ] || exit ${CGROUP_UNREADABLE_EXIT}`,
90
100
  'while read -r __scx_key __scx_value; do case "$__scx_key" in ' +
91
101
  'oom) __scx_oom=$__scx_value;; oom_kill) __scx_kill=$__scx_value;; esac; ' +
92
102
  'done < "$1/memory.events"',
93
- 'case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit 3;; esac',
103
+ `case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit ${CGROUP_UNREADABLE_EXIT};; esac`,
94
104
  '[ ! -r "$1/memory.max" ] || read -r __scx_max < "$1/memory.max"',
95
105
  '[ ! -r "$1/memory.peak" ] || read -r __scx_peak < "$1/memory.peak"',
96
106
  'printf \'%s %s %s %s %s\\n\' "${__scx_max:--}" "${__scx_peak:--}" ' +
@@ -116,15 +126,21 @@ const CGROUP_SAMPLING_OFF_REASON =
116
126
  * successful read proves the container runs, so Docker is asked for liveness
117
127
  * only after a failed round or when the stream ends. Without a usable stream it falls back
118
128
  * to one `docker exec` per round; without `sh` in the image (exit 126/127) or
119
- * without any readable cgroup it records the reason and stops.
120
- * `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables sampling.
129
+ * without any readable cgroup (exit 3 from either exec, issue #247) it
130
+ * records the reason and stops. Other failed rounds are retried, each one
131
+ * doubling the wait up to CGROUP_SAMPLE_BACKOFF_MAX_SECONDS; a sample restores
132
+ * the interval. `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables
133
+ * sampling.
121
134
  * @param {string} containerName - Container to sample
122
135
  * @returns {string} Shell command
123
136
  */
124
137
  function buildCgroupSamplerStartSnippet(containerName) {
125
138
  const v = CGROUP_SHELL_VARS;
126
139
  const name = shellQuote(containerName);
127
- const remoteRead = '__start_command_cgroup_read /sys/fs/cgroup';
140
+ const remoteRead = `__start_command_cgroup_read /sys/fs/cgroup || exit ${CGROUP_UNREADABLE_EXIT}`;
141
+ // Exit statuses no later round can change: no `sh`, or no counters.
142
+ const permanent = `${CGROUP_UNREADABLE_EXIT}|126|127`;
143
+ const backoff = CGROUP_SAMPLE_BACKOFF_MAX_SECONDS;
128
144
  const unavailable = (detail) =>
129
145
  `printf '%s\\n' "local cgroup unavailable; ${detail}" > "$${v.file}.reason"`;
130
146
  const execReason = unavailable(
@@ -155,7 +171,8 @@ function buildCgroupSamplerStartSnippet(containerName) {
155
171
  `__scs_dir=$(__start_command_cgroup_dir ${name}); ` +
156
172
  // The namespace is fixed at container creation, including when reads fail.
157
173
  `__scs_ns=$(docker inspect -f '{{.HostConfig.CgroupnsMode}}' ${name} 2>/dev/null); __scs_stream=1; __scs_reader=''; ` +
158
- `while :; do __scs_ok=''; if [ -n "$__scs_dir" ]; then ` +
174
+ `__scs_delay=$__scs_interval; ` +
175
+ `while :; do __scs_ok=''; __scs_sampled=''; if [ -n "$__scs_dir" ]; then ` +
159
176
  '__scs_line=$(__start_command_cgroup_read "$__scs_dir") && __scs_record "$__scs_line" && __scs_ok=1; ' +
160
177
  `elif [ "$__scs_ns" != private ]; then ${unavailable('container cgroup namespace=${__scs_ns:-unavailable}, private required')}; break; ` +
161
178
  `elif [ "$__scs_stream" = 1 ]; then ` +
@@ -171,15 +188,20 @@ function buildCgroupSamplerStartSnippet(containerName) {
171
188
  `[ -z "$__scs_got" ] || exit 0; ${execReason}; ` +
172
189
  `case "$__scs_exit" in ''|0|*[!0-9]*) exit 1;; esac; exit "$__scs_exit"; } & ` +
173
190
  `__scs_reader=$!; wait "$__scs_reader"; __scs_exit=$?; trap - TERM; __scs_reader=''; ` +
174
- // No shell in the image: a per-round exec cannot succeed either.
175
- `case "$__scs_exit" in 0) ;; 126|127) break;; *) __scs_stream=0;; esac; ` +
191
+ // No shell in the image or no counters: a per-round exec cannot
192
+ // succeed either.
193
+ `case "$__scs_exit" in 0) __scs_sampled=1;; ${permanent}) break;; *) __scs_stream=0;; esac; ` +
176
194
  `else __scs_remote=$(docker exec ${name} sh -c ${shellQuote(`${buildCgroupFunctionsSnippet()}; ${remoteRead}`)} 2>&1); __scs_exit=$?; ` +
177
195
  'if [ "$__scs_exit" = 0 ] && __scs_record "$__scs_remote"; then __scs_ok=1; ' +
178
- `else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ${execReason}; fi; fi; ` +
196
+ `else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ${execReason}; ` +
197
+ `case "$__scs_exit" in ${permanent}) break;; esac; fi; fi; ` +
179
198
  // A successful read needs no Docker liveness probe, and the completion
180
199
  // watcher still stops us after docker wait.
181
200
  `if [ -z "$__scs_ok" ] && [ "$(docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = false ]; then break; fi; ` +
182
- `sleep "$__scs_interval"; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
201
+ // Back off while rounds keep failing, never below the interval.
202
+ `if [ -n "$__scs_ok$__scs_sampled" ]; then __scs_delay=$__scs_interval; else __scs_delay=$((__scs_delay*2)); ` +
203
+ `[ "$__scs_delay" -le ${backoff} ] || __scs_delay=${backoff}; [ "$__scs_delay" -ge "$__scs_interval" ] || __scs_delay=$__scs_interval; fi; ` +
204
+ `sleep "$__scs_delay"; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
183
205
  ].join('; ');
184
206
  }
185
207
 
@@ -373,10 +395,12 @@ function formatCgroupMemory(memory) {
373
395
  }
374
396
 
375
397
  module.exports = {
398
+ CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
376
399
  CGROUP_SAMPLE_INTERVAL_SECONDS,
377
400
  CGROUP_SAMPLING_OFF_REASON,
378
401
  CGROUP_SHELL_VARS,
379
402
  CGROUP_STREAM_NAME,
403
+ CGROUP_UNREADABLE_EXIT,
380
404
  OOM_SCOPE,
381
405
  OOM_SCOPE_NOTES,
382
406
  buildCgroupFunctionsSnippet,
@@ -9,7 +9,11 @@ const os = require('os');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { getTimestamp } = require('./isolation');
12
- const { sanitizeLogToTemp, StreamingSanitizer } = require('./log-sanitizer');
12
+ const {
13
+ SanitizationError,
14
+ sanitizeLogToTemp,
15
+ sanitizeText,
16
+ } = require('./log-sanitizer');
13
17
 
14
18
  /**
15
19
  * Handle command failure - detect repository, upload log, create issue
@@ -296,8 +300,12 @@ function uploadLog(logPath, options = {}) {
296
300
  }
297
301
 
298
302
  return null;
299
- } catch {
300
- console.log('Warning: Log preparation or upload failed; upload blocked.');
303
+ } catch (error) {
304
+ console.log(
305
+ error instanceof SanitizationError
306
+ ? `Warning: ${error.message}`
307
+ : 'Warning: Log preparation or upload failed; upload blocked.'
308
+ );
301
309
  return null;
302
310
  } finally {
303
311
  if (prepared) {
@@ -334,9 +342,7 @@ function canCreateIssue(owner, repo) {
334
342
  */
335
343
  function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
336
344
  try {
337
- fullCommand = new StreamingSanitizer()
338
- .push(Buffer.from(fullCommand), true)
339
- .toString();
345
+ fullCommand = sanitizeText(fullCommand);
340
346
  const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
341
347
 
342
348
  // Get runtime information