@ctrl-spc/cs 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents.js +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +501 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +7627 -298
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +677 -0
- package/dist/panel3/run.js +1988 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/show.js +996 -0
- package/dist/panel3/spawn.js +503 -0
- package/dist/panel3/tools.js +1601 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/package.json +3 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ═══ AGENT PANEL v3: `cs3 say`, the only way anything reaches the record. ═══
|
|
3
|
+
*
|
|
4
|
+
* THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
|
|
5
|
+
* import it.
|
|
6
|
+
*
|
|
7
|
+
* ---------------------------------------------------------------------------
|
|
8
|
+
* ═══ THE PRODUCT WRITES THE CARD, WITH NO AGENT ANYWHERE. ═══
|
|
9
|
+
*
|
|
10
|
+
* ux.md: "The card IS the acknowledgement. It appears on send... written by the
|
|
11
|
+
* product on send, before any agent exists. An acknowledgement that waits for a
|
|
12
|
+
* spawn is not an acknowledgement."
|
|
13
|
+
*
|
|
14
|
+
* So this command's whole job is to put the card and the turn on the record and
|
|
15
|
+
* return. It does not spawn, does not take, does not wait for anything to be
|
|
16
|
+
* ready, and there is nothing here for a daemon to be up for. Nothing answers
|
|
17
|
+
* the turn yet, which is why `cs3 show` says out loud that nothing is acting on
|
|
18
|
+
* the card: that is the record being honest, not this command being unfinished.
|
|
19
|
+
*
|
|
20
|
+
* ---------------------------------------------------------------------------
|
|
21
|
+
* WHY A TITLE IS DERIVED HERE RATHER THAN ASKED FOR.
|
|
22
|
+
*
|
|
23
|
+
* `panel3_cards.title` is `not null` and non-blank, because a card with no name
|
|
24
|
+
* cannot be read on a board. The user typed one thing, so the first line of it
|
|
25
|
+
* is the card's name. Nothing else exists yet that could name it better, and a
|
|
26
|
+
* second `--title` flag would make the user say twice what they said once.
|
|
27
|
+
*
|
|
28
|
+
* ---------------------------------------------------------------------------
|
|
29
|
+
* ═══ SEND ORDER IS MEANING, SO A TURN IS ONLY EVER APPENDED. ═══
|
|
30
|
+
*
|
|
31
|
+
* ux.md: "'Approve that' followed by 'actually do it differently' reads as a
|
|
32
|
+
* sequence and is answerable. Unordered, it is two contradictory demands." Every
|
|
33
|
+
* send is a new row stamped with the time it arrived, and nothing here edits or
|
|
34
|
+
* replaces one, so the order the user sent in is the order the record holds.
|
|
35
|
+
*
|
|
36
|
+
* ---------------------------------------------------------------------------
|
|
37
|
+
* ═══ `--card` NAMING A CARD THAT IS NOT THERE FAILS, AND NEVER CREATES ONE. ═══
|
|
38
|
+
*
|
|
39
|
+
* The two behaviours are different requests: `say` starts a request, `say
|
|
40
|
+
* --card` continues one. Falling back from the second to the first would put the
|
|
41
|
+
* user's message on a brand new card while they believed they had answered on
|
|
42
|
+
* the old one, which is the fenced-off answer ux.md spends a section on, arrived
|
|
43
|
+
* at by a different route.
|
|
44
|
+
*/
|
|
45
|
+
// The runtime import comes FIRST, deliberately: `tsc` elides a type-only import
|
|
46
|
+
// and takes the leading comment with it, so a file whose first statement is
|
|
47
|
+
// `import type` loses its v3 header in the published `dist/`.
|
|
48
|
+
import { at, out, returned, signedInClient } from './client.js';
|
|
49
|
+
const USAGE = 'usage: cs3 say [--card <id>] [--project <id or name>] "<text>"';
|
|
50
|
+
/** Long enough to recognise a request on a board, short enough to be a name. */
|
|
51
|
+
const TITLE_LIMIT = 80;
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* The arguments after `say`. A bare word that is not a flag is the message, and
|
|
55
|
+
* a SECOND one is refused rather than joined: `cs3 say hello there` is a message
|
|
56
|
+
* the shell already split, and quietly gluing it back together would be this
|
|
57
|
+
* command guessing at what the user typed. Quoting is the fix and the error says
|
|
58
|
+
* so.
|
|
59
|
+
*/
|
|
60
|
+
function parseArgs(args) {
|
|
61
|
+
let cardId = null;
|
|
62
|
+
let project = null;
|
|
63
|
+
const words = [];
|
|
64
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
65
|
+
const arg = args[i];
|
|
66
|
+
if (arg === '--card') {
|
|
67
|
+
i += 1;
|
|
68
|
+
const value = args[i];
|
|
69
|
+
// A FLAG IS NOT A CARD ID. Swallowing the next token whatever it is sends
|
|
70
|
+
// `cs3 say --card --help "x"` to Postgres and surfaces "invalid input
|
|
71
|
+
// syntax for type uuid", which is a real error and the wrong one: the
|
|
72
|
+
// truth is that no card was named.
|
|
73
|
+
if (value === undefined || value.startsWith('--')) {
|
|
74
|
+
throw new Error(`--card needs a card id. ${USAGE}`);
|
|
75
|
+
}
|
|
76
|
+
cardId = value;
|
|
77
|
+
}
|
|
78
|
+
else if (arg === '--project') {
|
|
79
|
+
i += 1;
|
|
80
|
+
const value = args[i];
|
|
81
|
+
// Same reasoning as `--card` above, and it matters more here: a swallowed
|
|
82
|
+
// flag would be looked up as a project NAME and refused as "no project
|
|
83
|
+
// called --card", which blames the wrong argument.
|
|
84
|
+
if (value === undefined || value.startsWith('--')) {
|
|
85
|
+
throw new Error(`--project needs a project id or name. ${USAGE}`);
|
|
86
|
+
}
|
|
87
|
+
project = value;
|
|
88
|
+
}
|
|
89
|
+
else if (arg.startsWith('--')) {
|
|
90
|
+
throw new Error(`unknown option ${arg}. ${USAGE}`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
words.push(arg);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/* ═══ NAMING A PROJECT FOR A CARD THAT ALREADY EXISTS IS REFUSED, NOT
|
|
97
|
+
IGNORED. ═══ A card is filed once, when it is created; `--card` continues a
|
|
98
|
+
request that is already filed somewhere. Accepting both and silently using
|
|
99
|
+
neither would have somebody believe they had moved a card. Moving one is a
|
|
100
|
+
different act and this build does not have it. */
|
|
101
|
+
if (cardId && project) {
|
|
102
|
+
throw new Error('a card is already filed under its project, so --project cannot be given with --card. '
|
|
103
|
+
+ `${USAGE}`);
|
|
104
|
+
}
|
|
105
|
+
if (words.length === 0)
|
|
106
|
+
throw new Error(`say what? ${USAGE}`);
|
|
107
|
+
if (words.length > 1) {
|
|
108
|
+
throw new Error(`quote the message as one argument, got ${words.length}. ${USAGE}`);
|
|
109
|
+
}
|
|
110
|
+
const message = words[0];
|
|
111
|
+
// A blank message would produce a blank title, which the table rejects, and a
|
|
112
|
+
// turn nobody could read. Refused here so the reason names the message rather
|
|
113
|
+
// than a check constraint.
|
|
114
|
+
if (message.trim() === '')
|
|
115
|
+
throw new Error(`the message is empty. ${USAGE}`);
|
|
116
|
+
return { cardId, project, message };
|
|
117
|
+
}
|
|
118
|
+
/** The first line of what the user typed, as the card's name. */
|
|
119
|
+
function titleFrom(message) {
|
|
120
|
+
const line = message.trim().split('\n')[0].replace(/\s+/g, ' ').trim();
|
|
121
|
+
if (line.length <= TITLE_LIMIT)
|
|
122
|
+
return line;
|
|
123
|
+
return `${line.slice(0, TITLE_LIMIT - 3).trimEnd()}...`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The card `--card` names, or a truthful refusal. `maybeSingle` distinguishes an
|
|
127
|
+
* absent card from a failed read: it returns null data with no error only when
|
|
128
|
+
* the row genuinely is not there, and a read that could not run still errors.
|
|
129
|
+
*
|
|
130
|
+
* ═══ AND THAT IS WHY THIS ONE DOES NOT GO THROUGH `returned()`. ═══ The shared
|
|
131
|
+
* guard treats "no error and no data" as a result it does not understand, which
|
|
132
|
+
* is right for every other call here and wrong for exactly this one: here it is
|
|
133
|
+
* a fact about the record, and it has its own sentence saying what to do next.
|
|
134
|
+
*/
|
|
135
|
+
async function existingCard(client, cardId) {
|
|
136
|
+
const { data, error } = await client
|
|
137
|
+
.from('panel3_cards')
|
|
138
|
+
.select('id, title')
|
|
139
|
+
.eq('id', cardId)
|
|
140
|
+
.maybeSingle();
|
|
141
|
+
if (error)
|
|
142
|
+
throw new Error(`could not read card ${cardId}: ${error.message}`);
|
|
143
|
+
if (!data) {
|
|
144
|
+
throw new Error(`no card with id ${cardId}. Run \`cs3 say "<text>"\` with no --card to start a new one, `
|
|
145
|
+
+ 'or `cs3 show` to list the cards there are.');
|
|
146
|
+
}
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* ═══ THE CARD'S PROJECT, WHEN THERE IS ONLY ONE IT COULD BE. ═══
|
|
151
|
+
*
|
|
152
|
+
* `panel3_cards.project_id` existed from Task 1 and nothing wrote it, so every
|
|
153
|
+
* card read "no project yet" forever. A column nothing ever writes is not a
|
|
154
|
+
* nullable column, it is a dead one, and a card that cannot say which project it
|
|
155
|
+
* belongs to is a card the board cannot file.
|
|
156
|
+
*
|
|
157
|
+
* WHAT CAN HONESTLY BE KNOWN AT SEND TIME IS EXACTLY THIS. If the user has one
|
|
158
|
+
* project, every request they make is about it, and that is a fact about the
|
|
159
|
+
* record rather than a reading of the words. With two or more, the only clue is
|
|
160
|
+
* what they typed, and having the product classify a message at send time is the
|
|
161
|
+
* move ux.md spends a section forbidding: "the moment the product classifies, it
|
|
162
|
+
* has to be right, and being wrong silently discards something the user said."
|
|
163
|
+
* So it stays null, `cs3 show` says "no project yet", and that is the truth.
|
|
164
|
+
*
|
|
165
|
+
* IT IS A LABEL, NOT A WALL. ux.md: "Project is a filter the coordinator applies,
|
|
166
|
+
* never a wall it lives inside." Nothing downstream narrows a read to this
|
|
167
|
+
* project; level 1 sees everything the user has, whatever one card is filed
|
|
168
|
+
* under.
|
|
169
|
+
*
|
|
170
|
+
* `limit(2)` because the question is one-or-more-than-one, and a user with two
|
|
171
|
+
* hundred projects should not have their acknowledgement wait on a list nobody
|
|
172
|
+
* reads.
|
|
173
|
+
*
|
|
174
|
+
* ═══ AND ARCHIVED PROJECTS ARE NOT PROJECTS IT COULD BE. ═══ Without the
|
|
175
|
+
* filter, a user with one live project and one they archived last year gets two
|
|
176
|
+
* rows, so the card silently loses the fact this whole function exists to keep —
|
|
177
|
+
* and it loses it precisely for the user who has been here long enough to have
|
|
178
|
+
* archived something. The rule is "the only project it could be", and an
|
|
179
|
+
* archived one is not one it could be.
|
|
180
|
+
*/
|
|
181
|
+
async function onlyProject(client) {
|
|
182
|
+
const projects = await returned(client.from('projects').select('id').is('archived_at', null).limit(2), 'read', 'your projects');
|
|
183
|
+
return projects.length === 1 ? projects[0].id : null;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* ═══ THE PROJECT THE PERSON NAMED, BY ID OR BY NAME. ═══
|
|
187
|
+
*
|
|
188
|
+
* A person at a terminal has the name in front of them and the id nowhere, so
|
|
189
|
+
* refusing anything but a uuid would make the flag unusable by the only people
|
|
190
|
+
* who can type it. Both are matched against the SHARED `projects` table through
|
|
191
|
+
* their own RLS, which is the read AGENTS.md allows and the web app makes on
|
|
192
|
+
* every page.
|
|
193
|
+
*
|
|
194
|
+
* ═══ AN UNKNOWN NAME AND AN AMBIGUOUS ONE BOTH REFUSE THE SEND. ═══ Filing the
|
|
195
|
+
* card somewhere else, or nowhere, would be answering a different request from
|
|
196
|
+
* the one that was typed: the project decides which checkout the work happens
|
|
197
|
+
* in. The names are listed back, because the person is one keystroke from the
|
|
198
|
+
* right one.
|
|
199
|
+
*/
|
|
200
|
+
async function namedProject(client, named) {
|
|
201
|
+
const projects = await returned(client.from('projects').select('id, name').is('archived_at', null), 'read', 'your projects');
|
|
202
|
+
const wanted = named.trim().toLowerCase();
|
|
203
|
+
const matches = projects.filter((p) => p.id === named || (p.name ?? '').trim().toLowerCase() === wanted);
|
|
204
|
+
if (matches.length === 1)
|
|
205
|
+
return matches[0].id;
|
|
206
|
+
const known = projects.map((p) => p.name).sort().join(', ') || 'none';
|
|
207
|
+
if (matches.length === 0) {
|
|
208
|
+
throw new Error(`no project of yours is called ${named}. Yours are: ${known}`);
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`${matches.length} of your projects are called ${named}, so name it by id instead. `
|
|
211
|
+
+ `Yours are: ${known}`);
|
|
212
|
+
}
|
|
213
|
+
/** A new card, written by the product on send, filed under the project the
|
|
214
|
+
* person named, or under their project when there is only one it could be.
|
|
215
|
+
* Read first, write once: a failed read refuses the send outright rather than
|
|
216
|
+
* writing a card that quietly lost a fact it could have had. */
|
|
217
|
+
async function newCard(client, message, project) {
|
|
218
|
+
const projectId = project ? await namedProject(client, project) : await onlyProject(client);
|
|
219
|
+
return returned(client
|
|
220
|
+
.from('panel3_cards')
|
|
221
|
+
.insert({ title: titleFrom(message), project_id: projectId })
|
|
222
|
+
.select('id, title')
|
|
223
|
+
.single(), 'create', 'the card');
|
|
224
|
+
}
|
|
225
|
+
/** The user's words, appended. `role` is always `user` here: this command is the
|
|
226
|
+
* user's, and the agent side of the table belongs to the task that spawns one. */
|
|
227
|
+
async function addTurn(client, cardId, message) {
|
|
228
|
+
return returned(client
|
|
229
|
+
.from('panel3_turns')
|
|
230
|
+
.insert({ card_id: cardId, role: 'user', body: message })
|
|
231
|
+
.select('id, created_at, addressed_at')
|
|
232
|
+
.single(), 'add', `the turn to card ${cardId}`);
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
export async function say(args) {
|
|
236
|
+
const { cardId, project, message } = parseArgs(args);
|
|
237
|
+
const client = await signedInClient();
|
|
238
|
+
const card = cardId
|
|
239
|
+
? await existingCard(client, cardId)
|
|
240
|
+
: await newCard(client, message, project);
|
|
241
|
+
const isNew = !cardId;
|
|
242
|
+
let turn;
|
|
243
|
+
try {
|
|
244
|
+
turn = await addTurn(client, card.id, message);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
// The card is on the record and the message is not. Said out loud, with the
|
|
248
|
+
// id, because the alternative is a user who believes nothing was written
|
|
249
|
+
// and a nameless card sitting on their board.
|
|
250
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
251
|
+
if (isNew) {
|
|
252
|
+
throw new Error(`${reason}\ncard ${card.id} was created and carries no turn. `
|
|
253
|
+
+ `Retry with: cs3 say --card ${card.id} "<text>"`);
|
|
254
|
+
}
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
out(`card ${card.id}${isNew ? ' NEW' : ''}`);
|
|
258
|
+
out(`title ${card.title}`);
|
|
259
|
+
out(`turn ${turn.id} ${at(turn.created_at)} ${turn.addressed_at ? `addressed ${at(turn.addressed_at)}` : 'NOT ADDRESSED'}`);
|
|
260
|
+
out();
|
|
261
|
+
out(` cs3 show ${card.id}`);
|
|
262
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ═══ WHAT A RUN READ, AND WHERE IT IS NOT ALLOWED TO GO. ═══
|
|
3
|
+
*
|
|
4
|
+
* A credential is attached so an agent can USE it: put it in a header, an
|
|
5
|
+
* environment variable, a command. That happens inside the agent's own harness
|
|
6
|
+
* and is none of this file's business. What IS this file's business is
|
|
7
|
+
* everything the agent then WRITES, because in v3 every word an agent writes is
|
|
8
|
+
* a row in a hosted database and a line on a card somebody's colleague can open.
|
|
9
|
+
*
|
|
10
|
+
* The rule is v2's, and v2 states it in `mcp.ts`: a credential value must never
|
|
11
|
+
* reach a CLOUD ROW or the PANEL. v3 breaks that rule by default and would have
|
|
12
|
+
* shipped breaking it, because there is no v3 write that is not a cloud row.
|
|
13
|
+
*
|
|
14
|
+
* ═══ TWO HALVES, AND ONLY ONE OF THEM IS THIS FILE. ═══ The other half is the
|
|
15
|
+
* instruction `get_credential` returns, which tells the agent not to publish the
|
|
16
|
+
* value. This half is what makes the rule true when the instruction is not
|
|
17
|
+
* followed, which — v2 recorded it happening in both harnesses — is a thing that
|
|
18
|
+
* happens. Neither half replaces the other: an instruction alone is a hope, and
|
|
19
|
+
* a substitution alone would leave the agent believing it had published
|
|
20
|
+
* something it had not.
|
|
21
|
+
*
|
|
22
|
+
* ═══ WHY A COPY OF v2's, AND NOT AN IMPORT. ═══ `panel3/` imports nothing from
|
|
23
|
+
* v1 or v2, and `panel3-isolation.contract.test.mjs` enforces it. Both are
|
|
24
|
+
* implementing the same three lines of the same rule.
|
|
25
|
+
*/
|
|
26
|
+
/** run id -> (value -> the credential's STORED name). */
|
|
27
|
+
const secretsByRun = new Map();
|
|
28
|
+
/** Record a value this run was given, so anything it writes can be checked
|
|
29
|
+
* against it. An empty value is not recorded: substituting `''` would replace
|
|
30
|
+
* every gap in every string. */
|
|
31
|
+
export function rememberSecret(runId, value, name) {
|
|
32
|
+
if (!runId || !value)
|
|
33
|
+
return;
|
|
34
|
+
const forRun = secretsByRun.get(runId) ?? new Map();
|
|
35
|
+
forRun.set(value, name);
|
|
36
|
+
secretsByRun.set(runId, forRun);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every value this run read, substituted out of text on its way to a row.
|
|
40
|
+
*
|
|
41
|
+
* LITERAL, NEVER A RegExp: a real password contains `.`, `+`, `(`, so a pattern
|
|
42
|
+
* built from one would be malformed or would over-match. `String.replaceAll`
|
|
43
|
+
* with a STRING needle has no such problem — but its REPLACEMENT still honours
|
|
44
|
+
* `$&`, `` $` `` and `$'`, so a credential named "Ops $& token" would corrupt
|
|
45
|
+
* what it replaced. Hence the `$$` escape on the NAME.
|
|
46
|
+
*
|
|
47
|
+
* LONGEST FIRST, so a secret that contains another cannot leave a fragment of
|
|
48
|
+
* the longer one behind after the shorter one has been replaced.
|
|
49
|
+
*
|
|
50
|
+
* IDENTITY WHEN THE RUN READ NOTHING, which is nearly every run: the text comes
|
|
51
|
+
* back byte for byte, so nothing else in v3 has to reason about whether this
|
|
52
|
+
* touched it.
|
|
53
|
+
*/
|
|
54
|
+
export function redactSecrets(runId, text) {
|
|
55
|
+
if (!runId || !text)
|
|
56
|
+
return text;
|
|
57
|
+
const forRun = secretsByRun.get(runId);
|
|
58
|
+
if (!forRun || forRun.size === 0)
|
|
59
|
+
return text;
|
|
60
|
+
let out = text;
|
|
61
|
+
for (const value of [...forRun.keys()].sort((a, b) => b.length - a.length)) {
|
|
62
|
+
if (!out.includes(value))
|
|
63
|
+
continue;
|
|
64
|
+
out = out.replaceAll(value, `[redacted: ${(forRun.get(value) ?? 'a credential').replaceAll('$', '$$$$')}]`);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Drop everything this run read. Called from the run's own completion path,
|
|
69
|
+
* which runs on every ending, so a daemon that stays up for days does not keep
|
|
70
|
+
* a secret in memory for a run that ended on Tuesday. */
|
|
71
|
+
export function forgetSecrets(runId) {
|
|
72
|
+
secretsByRun.delete(runId);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Every string inside a tool call's arguments, redacted, structure untouched.
|
|
76
|
+
*
|
|
77
|
+
* ═══ WHY EVERY ARGUMENT OF EVERY TOOL, AND NOT A LIST OF THE RISKY ONES. ═══
|
|
78
|
+
* No v3 tool has any legitimate reason to be HANDED a secret. They write the
|
|
79
|
+
* record: a report, an activity line, a question, an answer, a responsibility
|
|
80
|
+
* for somebody being dispatched, an artifact, a comment. The thing a secret is
|
|
81
|
+
* FOR — the request that needs it — happens in the harness's own shell, which
|
|
82
|
+
* does not come through here at all. So the safe set is "all of them", and a
|
|
83
|
+
* list of risky ones would be a list to keep up to date every time a tool is
|
|
84
|
+
* added, which is the maintenance nobody does.
|
|
85
|
+
*
|
|
86
|
+
* RECURSIVE, because `dispatch` and the artifact tools take nested objects and
|
|
87
|
+
* arrays, and a secret does not become safe by being one level down.
|
|
88
|
+
*/
|
|
89
|
+
export function redactArgs(runId, args) {
|
|
90
|
+
if (typeof args === 'string')
|
|
91
|
+
return redactSecrets(runId, args);
|
|
92
|
+
if (Array.isArray(args))
|
|
93
|
+
return args.map((item) => redactArgs(runId, item));
|
|
94
|
+
if (args && typeof args === 'object') {
|
|
95
|
+
return Object.fromEntries(Object.entries(args).map(([k, v]) => [k, redactArgs(runId, v)]));
|
|
96
|
+
}
|
|
97
|
+
return args;
|
|
98
|
+
}
|