Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/__architecture__/validate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Architecture validation script.
*
Expand Down Expand Up @@ -155,6 +155,10 @@
// openplc-web parity explicit (this folder is byte-identical
// between repos).
if (rel.startsWith('middleware/shared/utils/')) return 'utils'
// Shared runtime-auth (RuntimeTokenManager) is pure, dependency-free logic
// reachable from adapters/backend/main on both platforms — same `utils` rule
// set, byte-identical between repos.
if (rel.startsWith('middleware/shared/runtime-auth/')) return 'utils'
if (rel.match(/^middleware\/adapters\/[^/]+\/components\//)) return 'adapter-components'
if (rel.startsWith('middleware/adapters/')) return 'adapters'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@ describe('createEditorCompilerPlatformPort', () => {
cleanBuild: false,
mainProcessBridge: {
makeRuntimeApiRequest: jest.fn(),
makeRuntimeApiUpload: jest.fn(),
},
compressSourceFolder: jest.fn(),
sendRuntimeUpload: jest.fn(),
pollTimeoutMs: 1000,
pollIntervalMs: 10,
startTimeoutMs: 1000,
Expand Down Expand Up @@ -321,7 +321,7 @@ describe('createEditorCompilerPlatformPort', () => {
})) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest']
const port = createEditorCompilerPlatformPort(
makeHandlers(),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }),
)
const result = await port.checkRuntimeVersion(
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
Expand All @@ -338,7 +338,7 @@ describe('createEditorCompilerPlatformPort', () => {
const log = jest.fn()
const port = createEditorCompilerPlatformPort(
makeHandlers(),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }),
)
const result = await port.checkRuntimeVersion(
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
Expand All @@ -355,7 +355,7 @@ describe('createEditorCompilerPlatformPort', () => {
const log = jest.fn()
const port = createEditorCompilerPlatformPort(
makeHandlers(),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }),
)
const result = await port.checkRuntimeVersion(
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
Expand Down
90 changes: 20 additions & 70 deletions src/backend/editor/compiler/compiler-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,19 @@
type LibraryCompileBridge = {
makeRuntimeApiRequest: <T = void>(
ipAddress: string,
jwtToken: string,
endpoint: string,
responseParser?: (data: string) => T,
) => Promise<{ success: true; data?: T } | { success: false; error: string }>
// Required to satisfy compileProgram's bridge contract; never invoked on the
// library path (it compiles with runtimeIpAddress=null, so no upload runs).
makeRuntimeApiUpload: (opts: {
ipAddress: string
fileBuffer: Buffer
filename: string
contentType: string
cleanBuild: boolean
onUploadAccepted?: (responseBody: string) => void
}) => Promise<{ success: true; data: string } | { success: false; error: string }>
loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] }
}

Expand Down Expand Up @@ -495,8 +504,8 @@

checkStrucppAvailability(): MethodsResult<string> {
try {
const { getVersion } = loadStrucpp()

Check warning on line 507 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe array destructuring of a tuple element with an error typed value
return { success: true, data: getVersion() }

Check warning on line 508 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe call of a(n) `error` type typed value

Check warning on line 508 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
} catch {
throw new Error('STruC++ not available. Run "npm run setup:binaries" to install it.')
}
Expand Down Expand Up @@ -1903,73 +1912,8 @@
})
}

/**
* Send a compiled program file to the runtime's `/api/upload-file`
* over HTTPS via a multipart/form-data POST. Pure transport — no
* polling, no PLC start, no UI logging. Used as the `uploadProgram`
* callback fed to the shared `deployRuntimeProgram` orchestrator.
*
* v3 callers pass `program.st` + `text/plain`; v4 callers pass the
* compiled zip + `application/zip`. `cleanBuild` toggles the
* `?clean=1` flag the runtime honours by wiping `build/` and ccache
* before compiling.
*/
private async sendRuntimeUpload(opts: {
hostname: string
jwtToken: string
filename: string
contentType: string
fileBuffer: Buffer
cleanBuild: boolean
onUploadAccepted?: (responseBody: string) => void
}): Promise<{ success: boolean; error?: string }> {
const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2)
const header = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${opts.filename}"\r\n` +
`Content-Type: ${opts.contentType}\r\n\r\n`,
)
const footer = Buffer.from(`\r\n--${boundary}--\r\n`)
const body = Buffer.concat([header, opts.fileBuffer, footer] as unknown as ReadonlyArray<Uint8Array>)

return new Promise<{ success: boolean; error?: string }>((resolve) => {
const req = https.request(
{
hostname: opts.hostname,
port: 8443,
path: opts.cleanBuild ? '/api/upload-file?clean=1' : '/api/upload-file',
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': body.length,
Authorization: `Bearer ${opts.jwtToken}`,
},
...getRuntimeHttpsOptions(),
} as https.RequestOptions,
(res: IncomingMessage) => {
let data = ''
res.on('data', (chunk: Buffer) => {
data += chunk.toString()
})
res.on('end', () => {
if (res.statusCode === 200) {
opts.onUploadAccepted?.(data)
resolve({ success: true })
} else {
resolve({ success: false, error: data || `HTTP ${res.statusCode}` })
}
})
},
)
req.setTimeout(300_000, () => {
req.destroy()
resolve({ success: false, error: 'Upload request timed out after 5 minutes' })
})
req.on('error', (err: Error) => resolve({ success: false, error: err.message }))
req.write(body)
req.end()
})
}
// Runtime upload moved to MainProcessBridge.makeRuntimeApiUpload so it shares
// the single token authority (transparent refresh + retry on an expired JWT).

// !! Deprecated: This method is a outdated implementation and should be removed.
async createXmlFile(
Expand Down Expand Up @@ -2457,10 +2401,17 @@
mainProcessBridge: {
makeRuntimeApiRequest: <T = void>(
ipAddress: string,
jwtToken: string,
endpoint: string,
responseParser?: (data: string) => T,
) => Promise<{ success: true; data?: T } | { success: false; error: string }>
makeRuntimeApiUpload: (opts: {
ipAddress: string
fileBuffer: Buffer
filename: string
contentType: string
cleanBuild: boolean
onUploadAccepted?: (responseBody: string) => void
}) => Promise<{ success: true; data: string } | { success: false; error: string }>
Comment on lines +2407 to +2414

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify compileProgram argument shape and remaining runtime JWT plumbing.
rg -n -C 3 'runtimeJwtToken|jwtToken|compileProgram\(|makeRuntimeApiUpload' src/backend/editor/compiler src/main src/middleware

Repository: Autonomy-Logic/openplc-editor

Length of output: 31981


🏁 Script executed:

grep -n -A 5 -B 5 "deviceContext" src/backend/editor/compiler/editor-compiler-platform-port.ts src/backend/editor/compiler/compiler-module.ts

Repository: Autonomy-Logic/openplc-editor

Length of output: 12890


🏁 Script executed:

cat -n src/backend/editor/compiler/editor-compiler-platform-port.ts | sed -n '30,80p'

Repository: Autonomy-Logic/openplc-editor

Length of output: 2393


🏁 Script executed:

grep -n -A 10 "assertEditorHttpsContext" src/middleware/shared/ports/compiler-platform-port.ts src/backend/editor/compiler/editor-compiler-platform-port.ts

Repository: Autonomy-Logic/openplc-editor

Length of output: 4975


🏁 Script executed:

grep -n -A 20 "export.*PlatformDeviceContext" src/middleware/shared/ports/compiler-platform-port.ts

Repository: Autonomy-Logic/openplc-editor

Length of output: 1023


Remove the runtimeJwtToken requirement to enable token-free runtime uploads.

The deviceContext construction at lines 2675-2677 still gates the existence of the context on runtimeJwtToken. Since the makeRuntimeApiUpload bridge implementation in the main process handles token refresh internally and no longer accepts a JWT argument, the upload pipeline is unnecessarily skipped when only runtimeIpAddress is available.

Update the guard to check runtimeIpAddress alone (or treat jwt as optional) so the context is passed to the pipeline, allowing the bridge to manage authentication transparently.

Current Code
const deviceContext =
  runtimeIpAddress && runtimeJwtToken
    ? { kind: 'editor-https' as const, ip: runtimeIpAddress, jwt: runtimeJwtToken }
    : undefined
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from 'node:child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/editor/compiler/compiler-module.ts` around lines 2407 - 2414, The
`deviceContext` gate in `compiler-module.ts` is still blocking runtime uploads
unless `runtimeJwtToken` is present, even though `makeRuntimeApiUpload` now
handles auth refresh internally. Update the `deviceContext` construction so it
depends on `runtimeIpAddress` alone, or make `jwt` optional, and keep the
context flowing into the upload pipeline when only `runtimeIpAddress` is
available. Use the `deviceContext` logic near the runtime upload setup and the
`makeRuntimeApiUpload` bridge contract as the key symbols to adjust.

/**
* Resolve a list of project-enabled library names to parsed
* `.stlib` archives. Bundled libraries are always-on and
Expand Down Expand Up @@ -2710,7 +2661,6 @@
cleanBuild: cleanBuild ?? false,
mainProcessBridge,
compressSourceFolder: (folderPath: string) => this.compressSourceFolder(folderPath),
sendRuntimeUpload: (opts) => this.sendRuntimeUpload(opts),
pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS,
pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS,
startTimeoutMs: POST_BUILD_START_TIMEOUT_MS,
Expand Down Expand Up @@ -2996,7 +2946,7 @@
_mainProcessPort.postMessage({
logLevel,
message: data,
...(compileError ? { compileError } : {}),

Check warning on line 2949 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
})
},
{ hasCBlocks, pous: knownPous, libraries, missingLibraries },
Expand Down
41 changes: 17 additions & 24 deletions src/backend/editor/compiler/editor-compiler-platform-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,28 +106,26 @@
mainProcessBridge: {
makeRuntimeApiRequest: <T = void>(
ipAddress: string,
jwtToken: string,
endpoint: string,
responseParser?: (data: string) => T,
) => Promise<{ success: true; data?: T } | { success: false; error: string }>
/** Upload the runtime-v4 program bundle. Owns token refresh internally
* (via the token authority), so the upload self-heals on expiry like every
* other runtime call. */
makeRuntimeApiUpload: (opts: {
ipAddress: string
fileBuffer: Buffer
filename: string
contentType: string
cleanBuild: boolean
onUploadAccepted?: (responseBody: string) => void
}) => Promise<{ success: true; data: string } | { success: false; error: string }>
}
/** Compress the source folder into the runtime v4 upload zip.
* Delegated through context so the port adapter doesn't pull
* in the `archiver`-dependent compressSourceFolder method (which
* has its own private state on CompilerModule). */
compressSourceFolder: (folderPath: string) => Promise<Buffer>
/** Send the upload request to a runtime device. Wraps
* CompilerModule.sendRuntimeUpload with the right multipart
* payload structure. */
sendRuntimeUpload: (opts: {
hostname: string
jwtToken: string
filename: string
contentType: string
fileBuffer: Buffer
cleanBuild: boolean
onUploadAccepted?: (responseBody: string) => void
}) => Promise<{ success: boolean; error?: string }>
/** Timeout for the post-upload compile-status poll. */
pollTimeoutMs: number
/** Interval for the post-upload compile-status poll. */
Expand Down Expand Up @@ -158,7 +156,7 @@
* adapter computes the same hash via `spark-md5`; both outputs
* are byte-identical.
*/
async computeMd5(input: string): Promise<string> {

Check warning on line 159 in src/backend/editor/compiler/editor-compiler-platform-port.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Async method 'computeMd5' has no 'await' expression
return createHash('md5').update(input).digest('hex')
},

Expand Down Expand Up @@ -384,9 +382,8 @@

const deployOutcome = await deployRuntimeProgram({
uploadProgram: () =>
context.sendRuntimeUpload({
hostname: deviceContext.ip,
jwtToken: deviceContext.jwt,
context.mainProcessBridge.makeRuntimeApiUpload({
ipAddress: deviceContext.ip,
filename: 'program.zip',
contentType: 'application/zip',
fileBuffer,
Expand All @@ -405,7 +402,7 @@
status: string
logs: string[]
exit_code: number | null
}>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => {
}>(deviceContext.ip, '/api/compilation-status', (data: string) => {
return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null }
})
if (!result.success) return { success: false, error: result.error }
Expand All @@ -414,7 +411,6 @@
fetchStartResponse: async () => {
const result = await context.mainProcessBridge.makeRuntimeApiRequest<string>(
deviceContext.ip,
deviceContext.jwt,
'/api/start-plc',
(data: string) => {
const parsed = JSON.parse(data) as { status?: string }
Expand Down Expand Up @@ -486,9 +482,8 @@
const fileBuffer = Buffer.from(args.programSt, 'utf-8')
const deployOutcome = await deployRuntimeProgram({
uploadProgram: () =>
context.sendRuntimeUpload({
hostname: deviceContext.ip,
jwtToken: deviceContext.jwt,
context.mainProcessBridge.makeRuntimeApiUpload({
ipAddress: deviceContext.ip,
filename: 'program.st',
contentType: 'text/plain',
fileBuffer,
Expand All @@ -507,7 +502,7 @@
status: string
logs: string[]
exit_code: number | null
}>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => {
}>(deviceContext.ip, '/api/compilation-status', (data: string) => {
return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null }
})
if (!result.success) return { success: false, error: result.error }
Expand All @@ -516,7 +511,6 @@
fetchStartResponse: async () => {
const result = await context.mainProcessBridge.makeRuntimeApiRequest<string>(
deviceContext.ip,
deviceContext.jwt,
'/api/start-plc',
(data: string) => {
const parsed = JSON.parse(data) as { status?: string }
Expand Down Expand Up @@ -556,7 +550,6 @@
fetchVersion: async () => {
const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ version: string }>(
deviceContext.ip,
'', // unauthenticated probe
'/api/version',
(data: string) => JSON.parse(data) as { version: string },
)
Expand Down
14 changes: 14 additions & 0 deletions src/frontend/hooks/use-runtime-polling.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { useCallback, useEffect, useRef } from 'react'

import type { PlcStatus } from '../../middleware/shared/ports/types'
Expand Down Expand Up @@ -171,6 +171,20 @@
}
}, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats, setEthercatStatus])

// Keep the store's connection token in lock-step with the platform's token
// authority. When the authority transparently refreshes an expired token
// (editor main process, or the web adapter's RuntimeTokenManager), it emits
// onTokenRefreshed; adopting it here means every store-reading consumer — the
// compile/upload pipeline, the connection-status UI — uses the live token
// instead of the one captured at login. Without this, an upload kicked off
// after the token aged out would use a stale token and 401.
useEffect(() => {
const unsubscribe = runtime.onTokenRefreshed?.((newToken) => {
useOpenPLCStore.getState().deviceActions.setRuntimeJwtToken(newToken)
})
return unsubscribe
}, [runtime])

useEffect(() => {
const { workspaceActions } = useOpenPLCStore.getState()

Expand Down
Loading
Loading